diff --git a/README.md b/README.md index 66b4985..f2db26e 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,13 @@ perfect choice for easy development of this project. Video demo - [YouTube video](https://youtu.be/vcedo59raS4) # Current gcode support -Commands G0, G1, G2, G3, G4, G17, G18, G19, G20, G21, G28, G53, G90, G91, G92, M2, M3, -M5, M30 are supported. Commands can be easily added, see -[gmachine.py](./cnc/gmachine.py) file. +Commands G0, G1, G2, G3, G4, G17, G18, G19, G20, G21, G28, G53, G90, G91, G92, +M2, M3, M5, M30, M104, M105, M106, M107, M109, M114, M140, M190 are supported. +Commands can be easily added, see [gmachine.py](./cnc/gmachine.py) file. Four axis are supported - X, Y, Z, E. +Circular interpolation for XY, ZX, YZ planes is supported. Spindle with rpm control is supported. -Circular interpolation for XY, ZX, YZ planes is supported. +Extruder and bed heaters are supported. # Config All configs are stored in [config.py](./cnc/config.py) and contain hardware diff --git a/cnc/config.py b/cnc/config.py index 75e6f1f..64097c5 100644 --- a/cnc/config.py +++ b/cnc/config.py @@ -13,6 +13,15 @@ TABLE_SIZE_Z_MM = 48 SPINDLE_MAX_RPM = 10000 +EXTRUDER_MAX_TEMPERATURE = 250 +BED_MAX_TEMPERATURE = 100 +MIN_TEMPERATURE = 40 +EXTRUDER_PID = {"P": 0.0993079964195, + "I": 0.00267775053311, + "D": 0.267775053311} +BED_PID = {"P": 5.06820175723, + "I": 0.0476413193519, + "D": 4.76413193519} # Pins config STEPPER_STEP_PIN_X = 16 @@ -26,6 +35,11 @@ STEPPER_DIR_PIN_E = 8 SPINDLE_PWM_PIN = 7 +FAN_PIN = 10 +EXTRUDER_HEATER_PIN = 9 +BED_HEATER_PIN = 11 +EXTRUDER_TEMPERATURE_SENSOR_CHANNEL = 0 +BED_TEMPERATURE_SENSOR_CHANNEL = 1 ENDSTOP_PIN_X = 12 ENDSTOP_PIN_Y = 6 diff --git a/cnc/enums.py b/cnc/enums.py index cef415b..b49f585 100644 --- a/cnc/enums.py +++ b/cnc/enums.py @@ -39,3 +39,12 @@ class RotationDirection(Enum): CW = RotationDirection("CW") CCW = RotationDirection("CCW") + + +class Heaters(Enum): + """ Enum for selecting heater. + """ + pass + +HEATER_EXTRUDER = Heaters("extruder") +HEATER_BED = Heaters("bed") diff --git a/cnc/gcode.py b/cnc/gcode.py index cb50cfc..21ab57e 100644 --- a/cnc/gcode.py +++ b/cnc/gcode.py @@ -24,6 +24,14 @@ def __init__(self, params): """ self.params = params + def has(self, arg_name): + """ + Check if value is specified. + :param arg_name: Value name. + :return: boolean value. + """ + return arg_name in self.params + def get(self, arg_name, default=None, multiply=1.0): """ Get value from gcode line. :param arg_name: Value name. diff --git a/cnc/gmachine.py b/cnc/gmachine.py index d5a03bc..02eab05 100644 --- a/cnc/gmachine.py +++ b/cnc/gmachine.py @@ -1,10 +1,11 @@ from __future__ import division -import time import cnc.logging_config as logging_config from cnc import hal from cnc.pulses import * from cnc.coordinates import * +from cnc.heater import * +from cnc.enums import * class GMachineException(Exception): @@ -29,14 +30,18 @@ def __init__(self): self._convertCoordinates = 0 self._absoluteCoordinates = 0 self._plane = None + self._fan_state = False + self._heaters = dict() self.reset() hal.init() def release(self): """ Return machine to original position and free all resources. """ - self._spindle(0) self.home() + self._spindle(0) + for h in self._heaters: + self._heaters[h].stop() hal.deinit() def reset(self): @@ -55,6 +60,35 @@ def _spindle(self, spindle_speed): hal.join() hal.spindle_control(100.0 * spindle_speed / SPINDLE_MAX_RPM) + def _fan(self, state): + hal.fan_control(state) + self._fan_state = state + + def _heat(self, heater, temperature, wait): + # check if sensor is ok + if heater == HEATER_EXTRUDER: + measure = hal.get_extruder_temperature + control = hal.extruder_heater_control + coefficients = EXTRUDER_PID + elif heater == HEATER_BED: + measure = hal.get_bed_temperature + control = hal.bed_heater_control + coefficients = BED_PID + else: + raise GMachineException("unknown heater") + try: + measure() + except (IOError, OSError): + raise GMachineException("can not measure temperature") + if heater in self._heaters: + self._heaters[heater].stop() + del self._heaters[heater] + if temperature != 0: + self._heaters[heater] = Heater(temperature, coefficients, measure, + control) + if wait: + self._heaters[heater].wait() + def __check_delta(self, delta): pos = self._position + delta if not pos.is_in_aabb(Coordinates(0.0, 0.0, 0.0, 0.0), @@ -211,12 +245,37 @@ def plane(self): """ return self._plane + def fan_state(self): + """ Check if fan is on. + :return True if fan is on, False otherwise. + """ + return self._fan_state + + def __get_target_temperature(self, heater): + if heater not in self._heaters: + return 0 + return self._heaters[heater].target_temperature() + + def extruder_target_temperature(self): + """ Return desired extruder temperature. + :return Temperature in Celsius, 0 if disabled. + """ + return self.__get_target_temperature(HEATER_EXTRUDER) + + def bed_target_temperature(self): + """ Return desired bed temperature. + :return Temperature in Celsius, 0 if disabled. + """ + return self.__get_target_temperature(HEATER_BED) + def do_command(self, gcode): """ Perform action. :param gcode: GCode object which represent one gcode line + :return String if any answer require, None otherwise. """ if gcode is None: - return + return None + answer = None logging.debug("got command " + str(gcode.params)) # read command c = gcode.command() @@ -232,15 +291,12 @@ def do_command(self, gcode): self._convertCoordinates) # coord = self._position + delta velocity = gcode.get('F', self._velocity) - spindle_rpm = gcode.get('S', self._spindle_rpm) pause = gcode.get('P', self._pause) radius = gcode.radius(Coordinates(0.0, 0.0, 0.0, 0.0), self._convertCoordinates) # check parameters if velocity <= 0 or velocity > STEPPER_MAX_VELOCITY_MM_PER_MIN: raise GMachineException("bad feed speed") - if spindle_rpm < 0 or spindle_rpm > SPINDLE_MAX_RPM: - raise GMachineException("bad spindle speed") if pause < 0: raise GMachineException("bad delay") # select command and run it @@ -278,19 +334,63 @@ def do_command(self, gcode): gcode.coordinates(Coordinates(0.0, 0.0, 0.0, 0.0), self._convertCoordinates) elif c == 'M3': # spindle on + spindle_rpm = gcode.get('S', self._spindle_rpm) + if spindle_rpm < 0 or spindle_rpm > SPINDLE_MAX_RPM: + raise GMachineException("bad spindle speed") self._spindle(spindle_rpm) + self._spindle_rpm = spindle_rpm elif c == 'M5': # spindle off self._spindle(0) elif c == 'M2' or c == 'M30': # program finish, reset everything. self.reset() + # extruder and bed heaters control + elif c == 'M104' or c == 'M109' or c == 'M140' or c == 'M190': + if c == 'M104' or c == 'M109': + heater = HEATER_EXTRUDER + elif c == 'M140' or c == 'M190': + heater = HEATER_BED + else: + raise Exception("Unexpected heater command") + wait = c == 'M109' or c == 'M190' + if not gcode.has("S"): + raise GMachineException("temperature is not specified") + t = gcode.get('S', 0) + if ((heater == HEATER_EXTRUDER and t > EXTRUDER_MAX_TEMPERATURE) or + (heater == HEATER_BED and t > BED_MAX_TEMPERATURE) or + t < MIN_TEMPERATURE) and t != 0: + raise GMachineException("bad temperature") + self._heat(heater, t, wait) + elif c == 'M105': # get temperature + try: + et = hal.get_extruder_temperature() + except (IOError, OSError): + et = None + try: + bt = hal.get_bed_temperature() + except (IOError, OSError): + bt = None + if et is None and bt is None: + raise GMachineException("can not measure temperature") + answer = "E:{} B:{}".format(et, bt) + elif c == 'M106': # fan control + if gcode.get('S', 1) != 0: + self._fan(True) + else: + self._fan(False) + elif c == 'M107': # turn off fan + self._fan(False) elif c == 'M111': # enable debug logging_config.debug_enable() + elif c == 'M114': # get current position + hal.join() + p = self.position() + answer = "X:{} Y:{} Z:{} E:{}".format(p.x, p.y, p.z, p.e) elif c is None: # command not specified(for example, just F was passed) pass else: raise GMachineException("unknown command") # save parameters on success self._velocity = velocity - self._spindle_rpm = spindle_rpm self._pause = pause logging.debug("position {}".format(self._position)) + return answer diff --git a/cnc/hal.py b/cnc/hal.py index 6463bc3..649fb46 100644 --- a/cnc/hal.py +++ b/cnc/hal.py @@ -5,18 +5,54 @@ # """ Initialize GPIO pins and machine itself, including calibration if # needed. Do not return till all procedure is completed. # """ -# logging.info("initialize hal") # do_something() # # # def spindle_control(percent): # """ Spindle control implementation. -# :param percent: Spindle speed in percent. 0 turns spindle off. +# :param percent: Spindle speed in percent 0..100. 0 turns spindle off. # """ -# logging.info("spindle control: {}%".format(percent)) # do_something() # # +# def fan_control(on_off): +# """ +# Cooling fan control. +# :param on_off: boolean value if fan is enabled. +# """ +# do_something() +# +# +# def extruder_heater_control(percent): +# """ Extruder heater control. +# :param percent: heater power in percent 0..100. 0 turns heater off. +# """ +# do_something() +# +# +# def bed_heater_control(percent): +# """ Hot bed heater control. +# :param percent: heater power in percent 0..100. 0 turns heater off. +# """ +# do_something() +# +# +# def get_extruder_temperature(): +# """ Measure extruder temperature. +# Can raise OSError or IOError on any issue with sensor. +# :return: temperature in Celsius. +# """ +# return measure() +# +# +# def get_bed_temperature(): +# """ Measure bed temperature. +# Can raise OSError or IOError on any issue with sensor. +# :return: temperature in Celsius. +# """ +# return measure() +# +# # def move(generator): # """ Move head to according pulses in PulseGenerator. # :param generator: PulseGenerator object @@ -49,6 +85,16 @@ raise NotImplementedError("hal.init() not implemented") if 'spindle_control' not in locals(): raise NotImplementedError("hal.spindle_control() not implemented") +if 'fan_control' not in locals(): + raise NotImplementedError("hal.fan_control() not implemented") +if 'extruder_heater_control' not in locals(): + raise NotImplementedError("hal.extruder_heater_control() not implemented") +if 'bed_heater_control' not in locals(): + raise NotImplementedError("hal.bed_heater_control() not implemented") +if 'get_extruder_temperature' not in locals(): + raise NotImplementedError("hal.get_extruder_temperature() not implemented") +if 'get_bed_temperature' not in locals(): + raise NotImplementedError("hal.get_bed_temperature() not implemented") if 'move' not in locals(): raise NotImplementedError("hal.move() not implemented") if 'join' not in locals(): diff --git a/cnc/hal_raspberry/hal.py b/cnc/hal_raspberry/hal.py index e944c10..67304e7 100644 --- a/cnc/hal_raspberry/hal.py +++ b/cnc/hal_raspberry/hal.py @@ -3,6 +3,7 @@ from cnc.hal_raspberry import rpgpio from cnc.pulses import * from cnc.config import * +from cnc.sensors import thermistor US_IN_SECONDS = 1000000 @@ -32,7 +33,13 @@ def init(): gpio.init(ENDSTOP_PIN_X, rpgpio.GPIO.MODE_INPUT_PULLUP) gpio.init(ENDSTOP_PIN_X, rpgpio.GPIO.MODE_INPUT_PULLUP) gpio.init(SPINDLE_PWM_PIN, rpgpio.GPIO.MODE_OUTPUT) + gpio.init(FAN_PIN, rpgpio.GPIO.MODE_OUTPUT) + gpio.init(EXTRUDER_HEATER_PIN, rpgpio.GPIO.MODE_OUTPUT) + gpio.init(BED_HEATER_PIN, rpgpio.GPIO.MODE_OUTPUT) gpio.clear(SPINDLE_PWM_PIN) + gpio.clear(FAN_PIN) + gpio.clear(EXTRUDER_HEATER_PIN) + gpio.clear(BED_HEATER_PIN) # calibration gpio.set(STEPPER_DIR_PIN_X) @@ -86,7 +93,7 @@ def init(): def spindle_control(percent): """ Spindle control implementation. - :param percent: spindle speed in percent. If 0, stop the spindle. + :param percent: spindle speed in percent 0..100. If 0, stop the spindle. """ logging.info("spindle control: {}%".format(percent)) if percent > 0: @@ -95,6 +102,53 @@ def spindle_control(percent): pwm.remove_pin(SPINDLE_PWM_PIN) +def fan_control(on_off): + """ + Cooling fan control. + :param on_off: boolean value if fan is enabled. + """ + if on_off: + logging.info("Fan is on") + gpio.set(FAN_PIN) + else: + logging.info("Fan is off") + gpio.clear(FAN_PIN) + + +def extruder_heater_control(percent): + """ Extruder heater control. + :param percent: heater power in percent 0..100. 0 turns heater off. + """ + if percent > 0: + pwm.add_pin(EXTRUDER_HEATER_PIN, percent) + else: + pwm.remove_pin(EXTRUDER_HEATER_PIN) + + +def bed_heater_control(percent): + """ Hot bed heater control. + :param percent: heater power in percent 0..100. 0 turns heater off. + """ + if percent > 0: + pwm.add_pin(BED_HEATER_PIN, percent) + else: + pwm.remove_pin(BED_HEATER_PIN) + + +def get_extruder_temperature(): + """ Measure extruder temperature. + :return: temperature in Celsius. + """ + return thermistor.get_temperature(EXTRUDER_TEMPERATURE_SENSOR_CHANNEL) + + +def get_bed_temperature(): + """ Measure bed temperature. + :return: temperature in Celsius. + """ + return thermistor.get_temperature(BED_TEMPERATURE_SENSOR_CHANNEL) + + def move(generator): """ Move head to specified position :param generator: PulseGenerator object. @@ -186,3 +240,7 @@ def deinit(): """ join() pwm.remove_all() + gpio.clear(SPINDLE_PWM_PIN) + gpio.clear(FAN_PIN) + gpio.clear(EXTRUDER_HEATER_PIN) + gpio.clear(BED_HEATER_PIN) diff --git a/cnc/hal_virtual.py b/cnc/hal_virtual.py index b99283e..f8f0065 100644 --- a/cnc/hal_virtual.py +++ b/cnc/hal_virtual.py @@ -17,12 +17,52 @@ def init(): def spindle_control(percent): - """ Spindle control implementation. + """ Spindle control implementation 0..100. :param percent: Spindle speed in percent. """ logging.info("spindle control: {}%".format(percent)) +def fan_control(on_off): + """Cooling fan control. + :param on_off: boolean value if fan is enabled. + """ + if on_off: + logging.info("Fan is on") + else: + logging.info("Fan is off") + + +# noinspection PyUnusedLocal +def extruder_heater_control(percent): + """ Extruder heater control. + :param percent: heater power in percent 0..100. 0 turns heater off. + """ + pass + + +# noinspection PyUnusedLocal +def bed_heater_control(percent): + """ Hot bed heater control. + :param percent: heater power in percent 0..100. 0 turns heater off. + """ + pass + + +def get_extruder_temperature(): + """ Measure extruder temperature. + :return: temperature in Celsius. + """ + return EXTRUDER_MAX_TEMPERATURE * 0.999 + + +def get_bed_temperature(): + """ Measure bed temperature. + :return: temperature in Celsius. + """ + return BED_MAX_TEMPERATURE * 0.999 + + # noinspection PyUnusedLocal def move(generator): """ Move head to specified position. diff --git a/cnc/heater.py b/cnc/heater.py new file mode 100644 index 0000000..b3a636a --- /dev/null +++ b/cnc/heater.py @@ -0,0 +1,94 @@ +import threading +import time +import logging + +from cnc.pid import Pid + + +class Heater(threading.Thread): + LOOP_INTERVAL_S = 0.5 + SENSOR_TIMEOUT_S = 1 + + def __init__(self, target_temp, pid_coefficients, measure_method, + control_method): + """ Initialize and run asynchronous heating. + :param target_temp: temperature which should be reached in Celsius. + :param pid_coefficients: dict with PID coefficients. + :param measure_method: Method which should be called to measure + temperature, it should return temperature in + Celsius. + :param control_method: Method which should be called to control heater + power, it should received one argument with + heater power in percent(0..100). + """ + self._current_power = 0 + threading.Thread.__init__(self) + self._pid = Pid(target_temp, pid_coefficients) + self._measure = measure_method + self._control = control_method + self._is_run = True + self._mutex = threading.Lock() + self.setDaemon(True) + self.start() + logging.info("Heating thread start, temperature {}/{} C" + .format(self._measure(), self.target_temperature())) + + def target_temperature(self): + """ Return target temperature which should be reached. + :return: + """ + return self._pid.target_value() + + def is_fixed(self): + """ Check if target value is reached and PID maintains this value. + :return: boolean value + """ + return self._pid.is_fixed() + + def run(self): + """ Thread worker implementation. There is a loop for PID control. + """ + last_error = None + while True: + self._mutex.acquire() + if not self._is_run: + break + try: + current_temperature = self._measure() + except (IOError, OSError): + self._control(0) + if last_error is None: + last_error = time.time() + else: + if time.time() - last_error > self.SENSOR_TIMEOUT_S: + logging.critical("No data from temperature sensor. Stop" + " heating.") + break + continue + last_error = None + self._current_power = self._pid.update(current_temperature) * 100 + self._control(self._current_power) + self._mutex.release() + time.sleep(self.LOOP_INTERVAL_S) + + def stop(self): + """ Stop heating and free this instance. + """ + # make sure that control will not be called in worker anymore. + self._mutex.acquire() + self._is_run = False + self._mutex.release() + self._control(0) + logging.info("Heating thread stop") + + def wait(self): + """ Block until target temperature is reached. + """ + i = 0 + while not self._pid.is_fixed(): + if i % 8 == 0: + logging.info("Heating... current temperature {} C, power {}%" + .format(self._measure(), int(self._current_power))) + i = 0 + i += 1 + time.sleep(0.25) diff --git a/cnc/main.py b/cnc/main.py index 24e0b63..dc7d796 100755 --- a/cnc/main.py +++ b/cnc/main.py @@ -30,11 +30,14 @@ def do_line(line): try: g = GCode.parse_line(line) - machine.do_command(g) + res = machine.do_command(g) except (GCodeException, GMachineException) as e: print('ERROR ' + str(e)) return False - print('OK') + if res is not None: + print('OK ' + res) + else: + print('OK') return True diff --git a/cnc/pid.py b/cnc/pid.py index 4de3998..70f79e2 100644 --- a/cnc/pid.py +++ b/cnc/pid.py @@ -3,28 +3,31 @@ class Pid(object): - # PID coefficients - P = 0.422 - I = 0.208 - D = 0.014 - WINDUP_LIMIT = 3.0 FIX_ACCURACY = 0.01 FIX_TIME_S = 2.5 - def __init__(self, target_value, start_time=time.time()): + def __init__(self, target_value, coefficients, start_time=None): """ Proportional-integral-derivative controller implementation. :param target_value: value which PID should achieve. + :param coefficients: dict with "P", "I" and "D" coefficients. :param start_time: start time, current system time by default. """ - self._last_time = start_time + if start_time is None: + self._last_time = time.time() + else: + self._last_time = start_time self._target_value = target_value + self.P = coefficients["P"] + self.I = coefficients["I"] + self.D = coefficients["D"] + self.WINDUP_LIMIT = 1.0 / self.I self._integral = 0 self._last_error = 0 self._is_target_fixed = False self._target_fix_timer = None - def update(self, current_value, current_time=time.time()): + def update(self, current_value, current_time=None): """ Update PID with new current value. :param current_value: current value. @@ -32,6 +35,8 @@ def update(self, current_value, current_time=time.time()): time if not specified. :return: value in range 0..1.0 which represents PID output. """ + if current_time is None: + current_time = time.time() delta_time = current_time - self._last_time self._last_time = current_time error = self._target_value - current_value @@ -60,16 +65,21 @@ def update(self, current_value, current_time=time.time()): return res def is_fixed(self): - """ - Check if target value is reached and PID maintains this value. + """ Check if target value is reached and PID maintains this value. :return: boolean value """ return self._is_target_fixed + def target_value(self): + """ Get target value. + :return: value. + """ + return self._target_value + # for test purpose, see details in corresponding test file if __name__ == "__main__": - p = Pid(230, 0) + p = Pid(230, {"P": 0.1000, "I": 0.0274, "D": 0.2055}, 0) c = 0.0039 h = 3.09 t0 = 25 diff --git a/cnc/sensors/thermistor.py b/cnc/sensors/thermistor.py index 45d6769..93e8212 100644 --- a/cnc/sensors/thermistor.py +++ b/cnc/sensors/thermistor.py @@ -49,6 +49,7 @@ def get_temperature(channel): """ Measure temperature on specified channel. + Can raise OSError or IOError on any issue with sensor. :param channel: ads111x channel. :return: temperature in Celsius """ @@ -65,6 +66,10 @@ def get_temperature(channel): if __name__ == "__main__": while True: for i in range(0, 4): - print("T{}={}".format(i, get_temperature(i))) + try: + t = get_temperature(i) + except (IOError, OSError): + t = None + print("T{}={}".format(i, t)) print("-----------------------------") time.sleep(0.5) diff --git a/tests/test_gcode.py b/tests/test_gcode.py index dd46c1a..1a58e64 100644 --- a/tests/test_gcode.py +++ b/tests/test_gcode.py @@ -20,6 +20,15 @@ def test_constructor(self): self.assertEqual(gc.coordinates(self.default, 1).z, 0.0) self.assertEqual(gc.coordinates(self.default, 1).e, 99.0) + def test_has(self): + gc = GCode.parse_line("g1X2Y3z4E5F50") + self.assertTrue(gc.has("G")) + self.assertTrue(gc.has("X")) + self.assertTrue(gc.has("Y")) + self.assertTrue(gc.has("Z")) + self.assertTrue(gc.has("E")) + self.assertTrue(gc.has("F")) + def test_parser(self): gc = GCode.parse_line("G1X2Y-3Z4E1.5") self.assertEqual(gc.command(), "G1") diff --git a/tests/test_gmachine.py b/tests/test_gmachine.py index 99d904d..2a02e97 100644 --- a/tests/test_gmachine.py +++ b/tests/test_gmachine.py @@ -3,11 +3,14 @@ from cnc.gcode import * from cnc.gmachine import * from cnc.coordinates import * +from cnc.heater import * +from cnc.pid import * class TestGMachine(unittest.TestCase): def setUp(self): - pass + Pid.FIX_TIME_S = 0.01 + Heater.LOOP_INTERVAL_S = 0.001 def tearDown(self): pass @@ -192,6 +195,58 @@ def test_m3_m5(self): m.do_command, GCode.parse_line("M3S999999999")) m.do_command(GCode.parse_line("M5")) + def test_m104_m109(self): + m = GMachine() + m.do_command(GCode.parse_line("M104S"+str(MIN_TEMPERATURE))) + self.assertEqual(m.extruder_target_temperature(), MIN_TEMPERATURE) + m.do_command(GCode.parse_line("M104S0")) + self.assertEqual(m.extruder_target_temperature(), 0) + # blocking heating should be called with max temperature since virtual + # hal always return this temperature. + m.do_command(GCode.parse_line("M109S" + str(EXTRUDER_MAX_TEMPERATURE))) + self.assertEqual(m.extruder_target_temperature(), + EXTRUDER_MAX_TEMPERATURE) + m.do_command(GCode.parse_line("M104S0")) + self.assertEqual(m.extruder_target_temperature(), 0) + self.assertRaises(GMachineException, m.do_command, + GCode.parse_line("M104S"+str(MIN_TEMPERATURE - 1))) + self.assertRaises(GMachineException, m.do_command, + GCode.parse_line("M109S" + + str(EXTRUDER_MAX_TEMPERATURE + 1))) + self.assertRaises(GMachineException, m.do_command, + GCode.parse_line("M109")) + + def test_m106_m107(self): + m = GMachine() + m.do_command(GCode.parse_line("M106")) + self.assertTrue(m.fan_state()) + m.do_command(GCode.parse_line("M106S0")) + self.assertFalse(m.fan_state()) + m.do_command(GCode.parse_line("M106S123")) + self.assertTrue(m.fan_state()) + m.do_command(GCode.parse_line("M107")) + self.assertFalse(m.fan_state()) + + def test_m140_m190(self): + m = GMachine() + m.do_command(GCode.parse_line("M140S"+str(MIN_TEMPERATURE))) + self.assertEqual(m.bed_target_temperature(), MIN_TEMPERATURE) + m.do_command(GCode.parse_line("M140S0")) + self.assertEqual(m.bed_target_temperature(), 0) + # blocking heating should be called with max temperature since virtual + # hal always return this temperature. + m.do_command(GCode.parse_line("M190S" + str(BED_MAX_TEMPERATURE))) + self.assertEqual(m.bed_target_temperature(), BED_MAX_TEMPERATURE) + m.do_command(GCode.parse_line("M190S0")) + self.assertEqual(m.bed_target_temperature(), 0) + self.assertRaises(GMachineException, m.do_command, + GCode.parse_line("M140S"+str(MIN_TEMPERATURE - 1))) + self.assertRaises(GMachineException, m.do_command, + GCode.parse_line("M190S" + + str(BED_MAX_TEMPERATURE + 1))) + self.assertRaises(GMachineException, m.do_command, + GCode.parse_line("M190")) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_heater.py b/tests/test_heater.py new file mode 100644 index 0000000..b52c6c7 --- /dev/null +++ b/tests/test_heater.py @@ -0,0 +1,82 @@ +import unittest + +from cnc.heater import * +from cnc.pid import * +from cnc.config import * + + +class TestHeater(unittest.TestCase): + def setUp(self): + self._target_temp = 100 + Pid.FIX_TIME_S = 0 + Heater.LOOP_INTERVAL_S = 0.001 + self._control_counter = 0 + + def tearDown(self): + pass + + def __get_temperature(self): + return self._target_temp + + def __get_bad_temperature(self): + return self._target_temp / 2 + + # noinspection PyUnusedLocal + def __control(self, percent): + self._control_counter += 1 + + def test_start_stop(self): + # check if thread stops correctly + he = Heater(self._target_temp, EXTRUDER_PID, self.__get_temperature, + self.__control) + self.assertEqual(self._target_temp, he.target_temperature()) + he.stop() + self._control_counter = 0 + he.join(5) + self.assertEqual(self._control_counter, 0) + self.assertFalse(he.is_alive()) + + def test_async(self): + # check asynchronous heating + self._control_counter = 0 + he = Heater(self._target_temp, EXTRUDER_PID, self.__get_temperature, + self.__control) + j = 0 + while self._control_counter < 3: + time.sleep(0.01) + j += 1 + if j > 500: + he.stop() + raise Exception("Heater timeout") + he.stop() + self.assertTrue(he.is_fixed()) + + def test_sync(self): + # test wait() method + self._control_counter = 0 + he = Heater(self._target_temp, EXTRUDER_PID, self.__get_temperature, + self.__control) + he.wait() + he.stop() + self.assertGreater(self._control_counter, 1) # one call for stop() + self.assertTrue(he.is_fixed()) + + def test_fail(self): + # check if heater will not fix with incorrect temperature + self._control_counter = 0 + he = Heater(self._target_temp, EXTRUDER_PID, self.__get_bad_temperature, + self.__control) + j = 0 + while self._control_counter < 10: + time.sleep(0.01) + j += 1 + if j > 500: + he.stop() + raise Exception("Heater timeout") + he.stop() + self.assertGreater(self._control_counter, 10) # one call for stop() + self.assertFalse(he.is_fixed()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pid.py b/tests/test_pid.py index 90fa4ff..222e800 100644 --- a/tests/test_pid.py +++ b/tests/test_pid.py @@ -1,6 +1,7 @@ import unittest from cnc.pid import * +from cnc.config import * class TestPid(unittest.TestCase): @@ -8,64 +9,78 @@ def setUp(self): self._environment_temp = 25 # Coefficients below were chosen by an experimental way with a real # hardware: reprap heating bed and extruder. - self._bed_c = 0.00231 # bed cooling coefficient - self._bed_h = 0.25 # bed heating coefficient - self._extruder_c = 0.0039 # extruder cooling coefficient - self._extruder_h = 3.09 # extruder heating coefficient + # See ../utils/heater_model_finder.py to find out this coefficients. + self._bed_c = 0.0027 # bed cooling coefficient + self._bed_h = 0.2522 # bed heating coefficient + self._extruder_c = 0.0108 # extruder cooling coefficient + self._extruder_h = 3.4070 # extruder heating coefficient def tearDown(self): pass - def __simulate(self, target_temp, environment_temp, cool, heat): + def __simulate(self, target_temp, pid_c, environment_temp, cool, heat): # Simulate heating some hypothetical thing with heater(h is a heat # transfer coefficient, which becomes just a delta temperature each # second) from environment temperature to target_temp. Consider that # there is natural air cooling process with some heat transfer # coefficient c. Heating power is controlled by PID. - pid = Pid(target_temp, 0) + pid = Pid(target_temp, pid_c, 0) temperature = environment_temp heater_power = 0 fixed_at = None zeros_counter = 0 - for j in range(1, 15 * 60 + 1): # simulate for 15 minutes + total_counter = 0 + iter_pes_s = 2 # step is 0.5s + j = 1 + for k in range(1, 20 * 60 * iter_pes_s + 1): # simulate for 20 minutes + j = k / float(iter_pes_s) # natural cooling - temperature -= (temperature - environment_temp) * cool + temperature -= ((temperature - environment_temp) * cool + / float(iter_pes_s)) # heating - temperature += heat * heater_power + temperature += heat * heater_power / float(iter_pes_s) heater_power = pid.update(temperature, j) if fixed_at is None: if pid.is_fixed(): fixed_at = j else: self.assertLess(abs(temperature - target_temp), - pid.FIX_ACCURACY * target_temp, + pid.FIX_ACCURACY * target_temp * 5.0, msg="PID failed to control temperature " "{}/{} {}".format(temperature, target_temp, j)) if heater_power == 0.0: zeros_counter += 1 - self.assertLess(zeros_counter, 20, msg="PID turns on/off, instead of " - "fine control") - self.assertLess(fixed_at, 600, - msg="failed to heat in 10 minutes, final temperature " + total_counter += 1 + self.assertLess(abs(temperature - target_temp), + pid.FIX_ACCURACY * target_temp, + msg="PID failed to control temperature " + "{}/{} {}".format(temperature, target_temp, j)) + self.assertLess(zeros_counter, total_counter * 0.05, + msg="PID turns on/off, instead of fine control") + self.assertLess(fixed_at, 900, + msg="failed to heat in 15 minutes, final temperature " "{}/{}".format(temperature, target_temp)) def test_simple(self): - pid = Pid(50, 0) + pid = Pid(50, EXTRUDER_PID, 0) + self.assertEqual(0, pid.update(100, 1)) + self.assertEqual(1, pid.update(0, 2)) + pid = Pid(50, BED_PID, 0) self.assertEqual(0, pid.update(100, 1)) self.assertEqual(1, pid.update(0, 2)) - - def test_bed(self): - # check if bed typical temperatures can be reached in simulation - for target in range(50, 101, 10): - self.__simulate(target, self._environment_temp, - self._bed_c, self._bed_h) def test_extruder(self): # check if extruder typical temperatures can be reached in simulation for target in range(150, 251, 10): - self.__simulate(target, self._environment_temp, + self.__simulate(target, EXTRUDER_PID, self._environment_temp, self._extruder_c, self._extruder_h) + def test_bed(self): + # check if bed typical temperatures can be reached in simulation + for target in range(50, 101, 10): + self.__simulate(target, BED_PID, self._environment_temp, + self._bed_c, self._bed_h) + if __name__ == '__main__': unittest.main()