diff --git a/README.md b/README.md index d90d67341..3c304ea4f 100644 --- a/README.md +++ b/README.md @@ -26,15 +26,17 @@ See the [Danger Features document](https://dangerklipper.io/Danger_Features.html - [fan: reverse FAN](https://github.com/DangerKlippers/danger-klipper/pull/51) ([klipper#4983](https://github.com/Klipper3d/klipper/pull/4983)) -- [heater: modify PID without reload](https://github.com/DangerKlippers/danger-klipper/pull/35) +- [heaters: modify PID without reload](https://github.com/DangerKlippers/danger-klipper/pull/35) -- [heater: velocity PID](https://github.com/DangerKlippers/danger-klipper/pull/47) ([klipper#6272](https://github.com/Klipper3d/klipper/pull/6272)) +- [heaters: MPC temperature control](https://github.com/DangerKlippers/danger-klipper/pull/333) -- [heater: PID-Profiles](https://github.com/DangerKlippers/danger-klipper/pull/162) +- [heaters: velocity PID](https://github.com/DangerKlippers/danger-klipper/pull/47) ([klipper#6272](https://github.com/Klipper3d/klipper/pull/6272)) -- [heater: expose heater thermistor out of min/max](https://github.com/DangerKlippers/danger-klipper/pull/182) +- [heaters: PID-Profiles](https://github.com/DangerKlippers/danger-klipper/pull/162) -- [heater/fan: new heated_fan module](https://github.com/DangerKlippers/danger-klipper/pull/259) +- [heaters: expose heater thermistor out of min/max](https://github.com/DangerKlippers/danger-klipper/pull/182) + +- [heaters/fan: new heated_fan module](https://github.com/DangerKlippers/danger-klipper/pull/259) - [gcode: jinja2.ext.do extension](https://github.com/DangerKlippers/danger-klipper/pull/26) ([klipper#5149](https://github.com/Klipper3d/klipper/pull/5149)) @@ -119,8 +121,6 @@ See the [Danger Features document](https://dangerklipper.io/Danger_Features.html If you're feeling adventurous, take a peek at the extra features in the bleeding-edge-v2 branch [feature documentation](docs/Bleeding_Edge.md) and [feature configuration reference](docs/Config_Reference_Bleeding_Edge.md): -- [heaters: mpc temperature control](https://github.com/DangerKlippers/danger-klipper/pull/268) - - [extruder/pa: do not smooth base extruder position, only advance](https://github.com/DangerKlippers/danger-klipper/pull/266) - [core: non-critical-mcus](https://github.com/DangerKlippers/danger-klipper/pull/265) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 22ac7e6ab..5ea4eea93 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -935,7 +935,8 @@ The extruder section is used to describe the heater parameters for the nozzle hotend along with the stepper controlling the extruder. See the [command reference](G-Codes.md#extruder) for additional information. See the [pressure advance guide](Pressure_Advance.md) for information -on tuning pressure advance. +on tuning pressure advance. See [PID](PID.md) or [MPC](MPC.md) for more +detailed information about the control methods. ``` [extruder] @@ -1022,12 +1023,14 @@ sensor_pin: # be smoothed to reduce the impact of measurement noise. The default # is 1 seconds. control: -# Control algorithm (either pid, pid_v or watermark). This parameter must +# Control algorithm (either pid, pid_v, watermark or mpc). This parameter must # be provided. pid_v should only be used on well calibrated heaters with # low to moderate noise. -pid_Kp: -pid_Ki: -pid_Kd: +# +# If control: pid or pid_v +#pid_Kp: +#pid_Ki: +#pid_Kd: # The proportional (pid_Kp), integral (pid_Ki), and derivative # (pid_Kd) settings for the PID feedback control system. Klipper # evaluates the PID settings with the following general formula: @@ -1037,11 +1040,23 @@ pid_Kd: # off and 1.0 being full on. Consider using the PID_CALIBRATE # command to obtain these parameters. The pid_Kp, pid_Ki, and pid_Kd # parameters must be provided for PID heaters. +# +# If control: watermark #max_delta: 2.0 # On 'watermark' controlled heaters this is the number of degrees in # Celsius above the target temperature before disabling the heater # as well as the number of degrees below the target before # re-enabling the heater. The default is 2 degrees Celsius. +# +# If control: mpc +# See MPC.md for details about these parameters. +#heater_power: +#cooling_fan: +#ambient_temp_sensor: +#filament_diameter: 1.75 +#filament_density: 1.2 +#filament_heat_capacity: 1.8 +# #pwm_cycle_time: 0.100 # Time in seconds for each software PWM cycle of the heater. It is # not recommended to set this unless there is an electrical diff --git a/docs/Danger_Features.md b/docs/Danger_Features.md index 70dadc5c4..93ef30791 100644 --- a/docs/Danger_Features.md +++ b/docs/Danger_Features.md @@ -34,7 +34,7 @@ - [`[z_tilt/quad_gantry_level] increasing_threshold`](./Config_Reference.md#z_tilt) allows you to customize the allowed variation when probing multiple times ## Heaters, Fans, and PID changes - +- [Model Predictive Control](./MPC.md) is an advanced temperature control method that offers an alternative to traditional PID control. - [Velocity PID](./PID.md) can be more accurate than positional PID, but is more susceptible to noisy sensors and may require larger smoothing times - [`PID_PROFILE [LOAD/SAVE]`](./G-Codes.md#pid_profile) allows you to calibrate and save PID profiles at multiple temperatures and fan speeds, and later restore them. With some clever macros, automatic per-material pid tuning is within reach! - [`SET_HEATER_PID HEATER= KP= KI= KD=`](./G-Codes.md#set_heater_pid) can update your PID parameters without a reload. diff --git a/klippy/extras/control_mpc.py b/klippy/extras/control_mpc.py new file mode 100644 index 000000000..c4d6610d7 --- /dev/null +++ b/klippy/extras/control_mpc.py @@ -0,0 +1,795 @@ +import math +import logging + +AMBIENT_TEMP = 25.0 +PIN_MIN_TIME = 0.100 + +FILAMENT_TEMP_SRC_AMBIENT = "ambient" +FILAMENT_TEMP_SRC_FIXED = "fixed" +FILAMENT_TEMP_SRC_SENSOR = "sensor" + + +class ControlMPC: + def __init__(self, profile, heater, load_clean=False, register=True): + self.profile = profile + self._load_profile() + self.heater = heater + self.heater_max_power = heater.get_max_power() * self.const_heater_power + + self.want_ambient_refresh = self.ambient_sensor is not None + self.state_block_temp = ( + AMBIENT_TEMP if load_clean else self._heater_temp() + ) + self.state_sensor_temp = self.state_block_temp + self.state_ambient_temp = AMBIENT_TEMP + + self.last_power = 0.0 + self.last_loss_ambient = 0.0 + self.last_loss_filament = 0.0 + self.last_time = 0.0 + self.last_temp_time = 0.0 + + self.printer = heater.printer + self.toolhead = None + + if not register: + return + + gcode = self.printer.lookup_object("gcode") + gcode.register_mux_command( + "MPC_CALIBRATE", + "HEATER", + heater.get_name(), + self.cmd_MPC_CALIBRATE, + desc=self.cmd_MPC_CALIBRATE_help, + ) + gcode.register_mux_command( + "MPC_SET", + "HEATER", + heater.get_name(), + self.cmd_MPC_SET, + desc=self.cmd_MPC_SET_help, + ) + + cmd_MPC_SET_help = "Set MPC parameter" + + def cmd_MPC_SET(self, gcmd): + self.const_filament_diameter = gcmd.get_float( + "FILAMENT_DIAMETER", self.const_filament_diameter + ) + self.const_filament_density = gcmd.get_float( + "FILAMENT_DENSITY", self.const_filament_density + ) + self.const_filament_heat_capacity = gcmd.get_float( + "FILAMENT_HEAT_CAPACITY", self.const_filament_heat_capacity + ) + + temp = gcmd.get("FILAMENT_TEMP", None) + if temp is not None: + temp = temp.lower().strip() + if temp == "sensor": + self.filament_temp_src = (FILAMENT_TEMP_SRC_SENSOR,) + elif temp == "ambient": + self.filament_temp_src = (FILAMENT_TEMP_SRC_AMBIENT,) + else: + try: + value = float(temp) + except ValueError: + raise gcmd.error( + f"Error on '{gcmd._commandline}': unable to parse FILAMENT_TEMP\n" + "Valid options are 'sensor', 'ambient', or number." + ) + self.filament_temp_src = (FILAMENT_TEMP_SRC_FIXED, value) + + self._update_filament_const() + + cmd_MPC_CALIBRATE_help = "Run MPC calibration" + + def cmd_MPC_CALIBRATE(self, gcmd): + cal = MpcCalibrate(self.printer, self.heater, self) + cal.run(gcmd) + + # Helpers + + def _heater_temp(self): + return self.heater.get_temp(self.heater.reactor.monotonic())[0] + + def _load_profile(self): + self.const_block_heat_capacity = self.profile["block_heat_capacity"] + self.const_ambient_transfer = self.profile["ambient_transfer"] + self.const_target_reach_time = self.profile["target_reach_time"] + self.const_heater_power = self.profile["heater_power"] + self.const_smoothing = self.profile["smoothing"] + self.const_sensor_responsiveness = self.profile["sensor_responsiveness"] + self.const_min_ambient_change = self.profile["min_ambient_change"] + self.const_steady_state_rate = self.profile["steady_state_rate"] + self.const_filament_diameter = self.profile["filament_diameter"] + self.const_filament_density = self.profile["filament_density"] + self.const_filament_heat_capacity = self.profile[ + "filament_heat_capacity" + ] + self.const_maximum_retract = self.profile["maximum_retract"] + self.filament_temp_src = self.profile["filament_temp_src"] + self._update_filament_const() + self.ambient_sensor = self.profile["ambient_temp_sensor"] + self.cooling_fan = self.profile["cooling_fan"] + self.const_fan_ambient_transfer = self.profile["fan_ambient_transfer"] + + def is_valid(self): + return ( + self.const_block_heat_capacity is not None + and self.const_ambient_transfer is not None + and self.const_sensor_responsiveness is not None + ) + + def check_valid(self): + if self.is_valid(): + return + name = self.heater.get_name() + raise self.printer.command_error( + f"Cannot activate '{name}' as MPC control is not fully configured.\n\n" + f"Run 'MPC_CALIBRATE' or ensure 'block_heat_capacity', 'sensor_responsiveness', and " + f"'ambient_transfer' settings are defined for '{name}'." + ) + + def _update_filament_const(self): + radius = self.const_filament_diameter / 2.0 + self.const_filament_cross_section_heat_capacity = ( + (radius * radius) # mm^2 + * math.pi # 1 + / 1000.0 # mm^3 => cm^3 + * self.const_filament_density # g/cm^3 + * self.const_filament_heat_capacity # J/g/K + ) + + # Control interface + + def temperature_update(self, read_time, temp, target_temp): + if not self.is_valid(): + self.heater.set_pwm(read_time, 0.0) + return + + dt = read_time - self.last_temp_time + if self.last_temp_time == 0.0 or dt < 0.0 or dt > 1.0: + dt = 0.1 + + # Extruder position + extrude_speed_prev = 0.0 + extrude_speed_next = 0.0 + if target_temp != 0.0: + if self.toolhead is None: + self.toolhead = self.printer.lookup_object("toolhead") + if self.toolhead is not None: + extruder = self.toolhead.get_extruder() + if ( + hasattr(extruder, "find_past_position") + and extruder.get_heater() == self.heater + ): + pos = extruder.find_past_position(read_time) + + pos_prev = extruder.find_past_position(read_time - dt) + pos_moved = max(-self.const_maximum_retract, pos - pos_prev) + extrude_speed_prev = pos_moved / dt + + pos_next = extruder.find_past_position(read_time + dt) + pos_move = max(-self.const_maximum_retract, pos_next - pos) + extrude_speed_next = pos_move / dt + + # Modulate ambient transfer coefficient with fan speed + ambient_transfer = self.const_ambient_transfer + if self.cooling_fan and len(self.const_fan_ambient_transfer) > 1: + fan_speed = max( + 0.0, min(1.0, self.cooling_fan.get_status(read_time)["speed"]) + ) + fan_break = fan_speed * (len(self.const_fan_ambient_transfer) - 1) + below = self.const_fan_ambient_transfer[math.floor(fan_break)] + above = self.const_fan_ambient_transfer[math.ceil(fan_break)] + if below != above: + frac = fan_break % 1.0 + ambient_transfer = below * (1 - frac) + frac * above + else: + ambient_transfer = below + + # Simulate + + # Expected power by heating at last power setting + expected_heating = self.last_power + # Expected power from block to ambient + block_ambient_delta = self.state_block_temp - self.state_ambient_temp + expected_ambient_transfer = block_ambient_delta * ambient_transfer + expected_filament_transfer = ( + block_ambient_delta + * extrude_speed_prev + * self.const_filament_cross_section_heat_capacity + ) + + # Expected block dT since last period + expected_block_dT = ( + ( + expected_heating + - expected_ambient_transfer + - expected_filament_transfer + ) + * dt + / self.const_block_heat_capacity + ) + self.state_block_temp += expected_block_dT + + # Expected sensor dT since last period + expected_sensor_dT = ( + (self.state_block_temp - self.state_sensor_temp) + * self.const_sensor_responsiveness + * dt + ) + self.state_sensor_temp += expected_sensor_dT + + # Correct + + smoothing = 1 - (1 - self.const_smoothing) ** dt + adjustment_dT = (temp - self.state_sensor_temp) * smoothing + self.state_block_temp += adjustment_dT + self.state_sensor_temp += adjustment_dT + + if self.want_ambient_refresh: + temp = self.ambient_sensor.get_temp(read_time)[0] + if temp != 0.0: + self.state_ambient_temp = temp + self.want_ambient_refresh = False + if (self.last_power > 0 and self.last_power < 1.0) or abs( + expected_block_dT + adjustment_dT + ) < self.const_steady_state_rate * dt: + if adjustment_dT > 0.0: + ambient_delta = max( + adjustment_dT, self.const_min_ambient_change * dt + ) + else: + ambient_delta = min( + adjustment_dT, -self.const_min_ambient_change * dt + ) + self.state_ambient_temp += ambient_delta + + # Output + + # Amount of power needed to reach the target temperature in the desired time + + heating_power = ( + (target_temp - self.state_block_temp) + * self.const_block_heat_capacity + / self.const_target_reach_time + ) + # Losses (+ = lost from block, - = gained to block) + block_ambient_delta = self.state_block_temp - self.state_ambient_temp + loss_ambient = block_ambient_delta * ambient_transfer + block_filament_delta = self.state_block_temp - self.filament_temp( + read_time, self.state_ambient_temp + ) + loss_filament = ( + block_filament_delta + * extrude_speed_next + * self.const_filament_cross_section_heat_capacity + ) + + if target_temp != 0.0: + # The required power is the desired heating power + compensation for all the losses + power = max( + 0.0, + min( + self.heater_max_power, + heating_power + loss_ambient + loss_filament, + ), + ) + else: + power = 0 + + duty = power / self.const_heater_power + + # logging.info( + # "mpc: [%.3f/%.3f] %.2f => %.2f / %.2f / %.2f = %.2f[%.2f+%.2f+%.2f] / %.2f, dT %.2f, E %.2f=>%.2f", + # dt, + # smoothing, + # temp, + # self.state_block_temp, + # self.state_sensor_temp, + # self.state_ambient_temp, + # power, + # heating_power, + # loss_ambient, + # loss_filament, + # duty, + # adjustment_dT, + # extrude_speed_prev, + # extrude_speed_next, + # ) + + self.last_power = power + self.last_loss_ambient = loss_ambient + self.last_loss_filament = loss_filament + self.last_temp_time = read_time + self.heater.set_pwm(read_time, duty) + + def filament_temp(self, read_time, ambient_temp): + src = self.filament_temp_src + if src[0] == FILAMENT_TEMP_SRC_FIXED: + return src[1] + elif ( + src[0] == FILAMENT_TEMP_SRC_SENSOR + and self.ambient_sensor is not None + ): + return self.ambient_sensor.get_temp(read_time)[0] + else: + return ambient_temp + + def check_busy(self, eventtime, smoothed_temp, target_temp): + return abs(target_temp - smoothed_temp) > 1.0 + + def update_smooth_time(self): + pass + + def get_profile(self): + return self.profile + + def get_type(self): + return "mpc" + + def get_status(self, eventtime): + return { + "temp_block": self.state_block_temp, + "temp_sensor": self.state_sensor_temp, + "temp_ambient": self.state_ambient_temp, + "power": self.last_power, + "loss_ambient": self.last_loss_ambient, + "loss_filament": self.last_loss_filament, + "filament_temp": self.filament_temp_src, + } + + +class MpcCalibrate: + def __init__(self, printer, heater, orig_control): + self.printer = printer + self.heater = heater + self.orig_control = orig_control + + def run(self, gcmd): + use_analytic = gcmd.get("USE_DELTA", None) is not None + ambient_max_measure_time = gcmd.get_float( + "AMBIENT_MAX_MEASURE_TIME", 20.0, above=0.0 + ) + ambient_measure_sample_time = gcmd.get_float( + "AMBIENT_MEASURE_SAMPLE_TIME", 5.0, below=ambient_max_measure_time + ) + fan_breakpoints = gcmd.get_int("FAN_BREAKPOINTS", 3, minval=2) + target_temp = gcmd.get_float("TARGET", 200.0, minval=90.0) + threshold_temp = gcmd.get_float( + "THRESHOLD", max(50.0, min(100, target_temp - 100.0)) + ) + + control = TuningControl(self.heater) + old_control = self.heater.set_control(control) + try: + ambient_temp = self.await_ambient(gcmd, control, threshold_temp) + samples = self.heatup_test(gcmd, target_temp, control) + first_res = self.process_first_pass( + samples, + self.orig_control.heater_max_power, + ambient_temp, + threshold_temp, + use_analytic, + ) + logging.info("First pass: %s", first_res) + + profile = dict(self.orig_control.profile) + for key in [ + "block_heat_capacity", + "ambient_transfer", + "sensor_responsiveness", + ]: + profile[key] = first_res[key] + new_control = ControlMPC(profile, self.heater, False, False) + new_control.state_block_temp = first_res["post_block_temp"] + new_control.state_sensor_temp = first_res["post_sensor_temp"] + new_control.state_ambient_temp = ambient_temp + self.heater.set_control(new_control) + + transfer_res = self.transfer_test( + gcmd, + ambient_max_measure_time, + ambient_measure_sample_time, + fan_breakpoints, + new_control, + first_res, + ) + second_res = self.process_second_pass( + first_res, + transfer_res, + ambient_temp, + self.orig_control.heater_max_power, + ) + logging.info("Second pass: %s", second_res) + + block_heat_capacity = ( + second_res["block_heat_capacity"] + if use_analytic + else first_res["block_heat_capacity"] + ) + sensor_responsiveness = ( + second_res["sensor_responsiveness"] + if use_analytic + else first_res["sensor_responsiveness"] + ) + ambient_transfer = second_res["ambient_transfer"] + fan_ambient_transfer = ", ".join( + [f"{p:.6g}" for p in second_res["fan_ambient_transfer"]] + ) + + cfgname = self.heater.get_name() + gcmd.respond_info( + f"Finished MPC calibration of heater '{cfgname}'\n" + "Measured:\n " + f" block_heat_capacity={block_heat_capacity:#.6g} [J/K]\n" + f" sensor_responsiveness={sensor_responsiveness:#.6g} [K/s/K]\n" + f" ambient_transfer={ambient_transfer:#.6g} [W/K]\n" + f" fan_ambient_transfer={fan_ambient_transfer} [W/K]\n" + ) + + configfile = self.heater.printer.lookup_object("configfile") + configfile.set(cfgname, "control", "mpc") + configfile.set( + cfgname, "block_heat_capacity", f"{block_heat_capacity:#.6g}" + ) + configfile.set( + cfgname, + "sensor_responsiveness", + f"{sensor_responsiveness:#.6g}", + ) + configfile.set( + cfgname, "ambient_transfer", f"{ambient_transfer:#.6g}" + ) + configfile.set( + cfgname, + "fan_ambient_transfer", + fan_ambient_transfer, + ) + + except self.printer.command_error as e: + raise gcmd.error("%s failed: %s" % (gcmd.get_command(), e)) + finally: + self.heater.set_control(old_control) + self.heater.alter_target(0.0) + + def wait_stable(self, cycles=5): + """ + We wait for the extruder to cycle x amount of times above and below the target + doing this should ensure the temperature is stable enough to give a good result + as a fallback if it stays within 0.1 degree for ~30 seconds it is also accepted + """ + + below_target = True + above_target = 0 + on_target = 0 + starttime = self.printer.reactor.monotonic() + + def process(eventtime): + nonlocal below_target, above_target, on_target + temp, target = self.heater.get_temp(eventtime) + if below_target and temp > target + 0.015: + above_target += 1 + below_target = False + elif not below_target and temp < target - 0.015: + below_target = True + if ( + above_target >= cycles + and (self.printer.reactor.monotonic() - starttime) > 30.0 + ): + return False + if above_target > 0 and abs(target - temp) < 0.1: + on_target += 1 + else: + on_target = 0 + if on_target >= 150: # in case the heating is super consistent + return False + return True + + self.printer.wait_while(process, True, 0.2) + + def wait_settle(self, max_rate): + last_temp = None + next_check = None + samples = [] + + def process(eventtime): + temp, _ = self.heater.get_temp(eventtime) + samples.append((eventtime, temp)) + while samples[0][0] < eventtime - 10.0: + samples.pop(0) + dT = samples[-1][1] - samples[0][1] + dt = samples[-1][0] - samples[0][0] + if dt < 8.0: + return True + rate = abs(dT / dt) + return not rate < max_rate + + self.printer.wait_while(process) + return samples[-1][1] + + def await_ambient(self, gcmd, control, minimum_temp): + self.heater.alter_target(1.0) # Turn on fan to increase settling speed + if self.orig_control.ambient_sensor is not None: + # If we have an ambient sensor we won't waste time waiting for ambient. + # We do however need to wait for sub minimum_temp(we pick -5 C relative). + reported = [False] + target = minimum_temp - 5 + + def process(eventtime): + temp, _ = self.heater.get_temp(eventtime) + ret = temp > target + if ret and not reported[0]: + gcmd.respond_info( + f"Waiting for heater to drop below {target} degrees celcius" + ) + reported[0] = True + return ret + + self.printer.wait_while(process) + self.heater.alter_target(0.0) + return self.orig_control.ambient_sensor.get_temp( + self.heater.reactor.monotonic() + )[0] + + gcmd.respond_info("Waiting for heater to settle at ambient temperature") + ambient_temp = self.wait_settle(0.01) + self.heater.alter_target(0.0) + return ambient_temp + + def heatup_test(self, gcmd, target_temp, control): + gcmd.respond_info( + "Performing heatup test, target is %.1f degrees" % (target_temp,) + ) + control.set_output(self.heater.get_max_power(), target_temp) + + control.logging = True + + def process(eventtime): + temp, _ = self.heater.get_temp(eventtime) + return temp < target_temp + + self.printer.wait_while(process) + control.logging = False + self.heater.alter_target(0.0) + + log = control.log + control.log = [] + return log + + def transfer_test( + self, + gcmd, + ambient_max_measure_time, + ambient_measure_sample_time, + fan_breakpoints, + control, + first_pass_results, + ): + target_temp = round(first_pass_results["post_block_temp"]) + self.heater.set_temp(target_temp) + gcmd.respond_info( + "Performing ambient transfer tests, target is %.1f degrees" + % (target_temp,) + ) + + self.wait_stable(5) + + fan = self.orig_control.cooling_fan + + fan_powers = [] + if fan is None: + power_base = self.measure_power( + ambient_max_measure_time, ambient_measure_sample_time + ) + gcmd.respond_info(f"Average stable power: {power_base} W") + else: + if fan is not None: + for idx in range(0, fan_breakpoints): + speed = idx / (fan_breakpoints - 1) + curtime = self.heater.reactor.monotonic() + print_time = fan.get_mcu().estimated_print_time(curtime) + fan.set_speed(print_time + PIN_MIN_TIME, speed) + gcmd.respond_info("Waiting for temperature to stabilize") + self.wait_stable(3) + gcmd.respond_info( + f"Temperature stable, measuring power usage with {speed*100.:.0f}% fan speed" + ) + power = self.measure_power( + ambient_max_measure_time, ambient_measure_sample_time + ) + gcmd.respond_info( + f"{speed*100.:.0f}% fan average power: {power:.2f} W" + ) + fan_powers.append((speed, power)) + curtime = self.heater.reactor.monotonic() + print_time = fan.get_mcu().estimated_print_time(curtime) + fan.set_speed(print_time + PIN_MIN_TIME, 0.0) + power_base = fan_powers[0][1] + + return { + "target_temp": target_temp, + "base_power": power_base, + "fan_powers": fan_powers, + } + + def measure_power(self, max_time, sample_time): + samples = [] + time = [0] + last_time = [None] + + def process(eventtime): + dt = eventtime - ( + last_time[0] if last_time[0] is not None else eventtime + ) + last_time[0] = eventtime + status = self.heater.get_status(eventtime) + samples.append((dt, status["control_stats"]["power"] * dt)) + time[0] += dt + return time[0] < max_time + + self.printer.wait_while(process) + + total_energy = 0 + total_time = 0 + for dt, energy in reversed(samples): + total_energy += energy + total_time += dt + if total_time > sample_time: + break + + return total_energy / total_time + + def fastest_rate(self, samples): + best = [-1, 0, 0] + base_t = samples[0][0] + for idx in range(2, len(samples)): + dT = samples[idx][1] - samples[idx - 2][1] + dt = samples[idx][0] - samples[idx - 2][0] + rate = dT / dt + if rate > best[0]: + sample = samples[idx - 1] + best = [sample[0] - base_t, sample[1], rate] + return best + + def process_first_pass( + self, + all_samples, + heater_power, + ambient_temp, + threshold_temp, + use_analytic, + ): + # Find a continous segment of samples that all lie in the threshold.. range + best_lower = None + for idx in range(0, len(all_samples)): + if all_samples[idx][1] > threshold_temp and best_lower is None: + best_lower = idx + elif all_samples[idx][1] < threshold_temp: + best_lower = None + + t1_time = all_samples[best_lower][0] - all_samples[0][0] + + samples = all_samples[best_lower:] + pitch = math.floor((len(samples) - 1) / 2) + # We pick samples 0, pitch, and 2pitch, ensuring matching time spacing + dt = samples[pitch][0] - samples[0][0] + t1 = samples[0][1] + t2 = samples[pitch][1] + t3 = samples[2 * pitch][1] + + asymp_T = (t2 * t2 - t1 * t3) / (2.0 * t2 - t1 - t3) + block_responsiveness = -math.log((t2 - asymp_T) / (t1 - asymp_T)) / dt + ambient_transfer = heater_power / (asymp_T - ambient_temp) + + block_heat_capacity = -1.0 + sensor_responsiveness = -1.0 + start_temp = all_samples[0][1] + + # Asymptotic method + if use_analytic: + block_heat_capacity = ambient_transfer / block_responsiveness + sensor_responsiveness = block_responsiveness / ( + 1.0 + - (start_temp - asymp_T) + * math.exp(-block_responsiveness * t1_time) + / (t1 - asymp_T) + ) + + # Differential method + if ( + not use_analytic + or block_heat_capacity < 0 + or sensor_responsiveness < 0 + ): + fastest_rate = self.fastest_rate(samples) + block_heat_capacity = heater_power / fastest_rate[2] + sensor_responsiveness = fastest_rate[2] / ( + fastest_rate[2] * fastest_rate[0] + + ambient_temp + - fastest_rate[0] + ) + + heat_time = all_samples[-1][0] - all_samples[0][0] + post_block_temp = asymp_T + (start_temp - asymp_T) * math.exp( + -block_responsiveness * heat_time + ) + post_sensor_temp = all_samples[-1][1] + + return { + "post_block_temp": post_block_temp, + "post_sensor_temp": post_sensor_temp, + "block_responsiveness": block_responsiveness, + "ambient_transfer": ambient_transfer, + "block_heat_capacity": block_heat_capacity, + "sensor_responsiveness": sensor_responsiveness, + "asymp_temp": asymp_T, + "t1": t1, + "t1_time": t1_time, + "t2": t2, + "start_temp": start_temp, + "dt": dt, + } + + def process_second_pass( + self, first_res, transfer_res, ambient_temp, heater_power + ): + target_ambient_temp = transfer_res["target_temp"] - ambient_temp + ambient_transfer = transfer_res["base_power"] / target_ambient_temp + asymp_T = ambient_temp + heater_power / ambient_transfer + block_responsiveness = ( + -math.log((first_res["t2"] - asymp_T) / (first_res["t1"] - asymp_T)) + / first_res["dt"] + ) + block_heat_capacity = ambient_transfer / block_responsiveness + sensor_responsiveness = block_responsiveness / ( + 1.0 + - (first_res["start_temp"] - asymp_T) + * math.exp(-block_responsiveness * first_res["t1_time"]) + / (first_res["t1"] - asymp_T) + ) + + fan_ambient_transfer = [ + power / target_ambient_temp + for (_speed, power) in transfer_res["fan_powers"] + ] + + return { + "ambient_transfer": ambient_transfer, + "block_responsiveness": block_responsiveness, + "block_heat_capacity": block_heat_capacity, + "sensor_responsiveness": sensor_responsiveness, + "asymp_temp": asymp_T, + "fan_ambient_transfer": fan_ambient_transfer, + } + + +class TuningControl: + def __init__(self, heater): + self.value = 0.0 + self.target = None + self.heater = heater + self.log = [] + self.logging = False + + def temperature_update(self, read_time, temp, target_temp): + if self.logging: + self.log.append((read_time, temp)) + self.heater.set_pwm(read_time, self.value) + + def check_busy(self, eventtime, smoothed_temp, target_temp): + return self.value != 0.0 or self.target != 0 + + def set_output(self, value, target): + self.value = value + self.target = target + self.heater.set_temp(target) + + def get_profile(self): + return {"name": "tuning"} + + def get_type(self): + return "tuning" diff --git a/klippy/extras/heaters.py b/klippy/extras/heaters.py index a1db2f341..bb838fc59 100644 --- a/klippy/extras/heaters.py +++ b/klippy/extras/heaters.py @@ -7,6 +7,12 @@ import os import logging import threading +from .control_mpc import ( + ControlMPC, + FILAMENT_TEMP_SRC_AMBIENT, + FILAMENT_TEMP_SRC_FIXED, + FILAMENT_TEMP_SRC_SENSOR, +) ###################################################################### @@ -130,6 +136,7 @@ def lookup_control(self, profile, load_clean=False): "watermark": ControlBangBang, "pid": ControlPID, "pid_v": ControlVelocityPID, + "mpc": ControlMPC, } ) return algos[profile["control"]](profile, self, load_clean) @@ -188,6 +195,8 @@ def set_temp(self, degrees): % (degrees, self.min_temp, self.max_temp) ) with self.lock: + if degrees != 0.0 and hasattr(self.control, "check_valid"): + self.control.check_valid() self.target_temp = degrees def get_temp(self, eventtime): @@ -235,16 +244,22 @@ def stats(self, eventtime): ) def get_status(self, eventtime): + control_stats = None with self.lock: target_temp = self.target_temp smoothed_temp = self.smoothed_temp last_pwm_value = self.last_pwm_value - return { + if hasattr(self.control, "get_status"): + control_stats = self.control.get_status(eventtime) + ret = { "temperature": round(smoothed_temp, 2), "target": target_temp, "power": last_pwm_value, "pid_profile": self.get_control().get_profile()["name"], } + if control_stats is not None: + ret["control_stats"] = control_stats + return ret def is_adc_faulty(self): if self.last_temp > self.max_temp or self.last_temp < self.min_temp: @@ -319,6 +334,114 @@ def _init_profile(self, config_section, name): temp_profile["max_delta"] = config_section.getfloat( "max_delta", 2.0, above=0.0 ) + elif control == "mpc": + temp_profile["block_heat_capacity"] = config_section.getfloat( + "block_heat_capacity", above=0.0, default=None + ) + temp_profile["ambient_transfer"] = config_section.getfloat( + "ambient_transfer", minval=0.0, default=None + ) + temp_profile["target_reach_time"] = config_section.getfloat( + "target_reach_time", above=0.0, default=2.0 + ) + temp_profile["smoothing"] = config_section.getfloat( + "smoothing", above=0.0, maxval=1.0, default=0.83 + ) + temp_profile["heater_power"] = config_section.getfloat( + "heater_power", above=0.0 + ) + temp_profile["sensor_responsiveness"] = config_section.getfloat( + "sensor_responsiveness", above=0.0, default=None + ) + temp_profile["min_ambient_change"] = config_section.getfloat( + "min_ambient_change", above=0.0, default=1.0 + ) + temp_profile["steady_state_rate"] = config_section.getfloat( + "steady_state_rate", above=0.0, default=0.5 + ) + temp_profile["filament_diameter"] = config_section.getfloat( + "filament_diameter", above=0.0, default=1.75 + ) + temp_profile["filament_density"] = config_section.getfloat( + "filament_density", above=0.0, default=1.2 + ) + temp_profile["filament_heat_capacity"] = ( + config_section.getfloat( + "filament_heat_capacity", above=0.0, default=0.0 + ) + ) + temp_profile["maximum_retract"] = config_section.getfloat( + "maximum_retract", above=0.0, default=2.0 + ) + + filament_temp_src_raw = config_section.get( + "filament_temperature_source", "ambient" + ) + temp = filament_temp_src_raw.lower().strip() + if temp == "sensor": + filament_temp_src = (FILAMENT_TEMP_SRC_SENSOR,) + elif temp == "ambient": + filament_temp_src = (FILAMENT_TEMP_SRC_AMBIENT,) + else: + try: + value = float(temp) + except ValueError: + raise config_section.error( + f"Unable to parse option 'filament_temperature_source' in section '{config_section.get_name()}'" + ) + filament_temp_src = (FILAMENT_TEMP_SRC_FIXED, value) + temp_profile["filament_temp_src"] = filament_temp_src + + ambient_sensor_name = config_section.get( + "ambient_temp_sensor", None + ) + ambient_sensor = None + if ambient_sensor_name is not None: + ambient_sensor = config_section.get_printer().load_object( + config_section, + ambient_sensor_name, + None, + ) + if ambient_sensor is None: + ambient_sensor = ( + config_section.get_printer().lookup_object( + ambient_sensor_name, None + ) + ) + if ambient_sensor is None: + raise config_section.error( + f"Unknown ambient_temp_sensor '{ambient_sensor_name}' specified" + ) + temp_profile["ambient_temp_sensor"] = ambient_sensor + + fan_name = config_section.get("cooling_fan", None) + fan = None + if fan_name is not None: + fan_obj = config_section.get_printer().load_object( + config_section, + fan_name, + None, + ) + if fan_obj is None: + fan_obj = config_section.get_printer().lookup_object( + fan_name, None + ) + if fan_obj is None: + raise config_section.error( + f"Unknown part_cooling_fan '{fan_name}' specified" + ) + if not hasattr(fan_obj, "fan") or not hasattr( + fan_obj.fan, "set_speed" + ): + raise config_section.error( + f"part_cooling_fan '{fan_name}' is not a valid fan object" + ) + fan = fan_obj.fan + temp_profile["cooling_fan"] = fan + + temp_profile["fan_ambient_transfer"] = ( + config_section.getfloatlist("fan_ambient_transfer", []) + ) elif control == "pid" or control == "pid_v": for key, (type, placeholder) in PID_PROFILE_OPTIONS.items(): can_be_none = ( @@ -572,7 +695,11 @@ def load_profile(self, profile_name, gcmd, verbose): msg += "Smooth Time: %.3f\n" % smooth_time msg += ( "PID Parameters: pid_Kp=%.3f pid_Ki=%.3f pid_Kd=%.3f\n" - % (profile["pid_kp"], profile["pid_ki"], profile["pid_kd"]) + % ( + profile["pid_kp"], + profile["pid_ki"], + profile["pid_kd"], + ) ) self.outer_instance.gcode.respond_info(msg) @@ -988,6 +1115,7 @@ def cmd_TEMPERATURE_WAIT(self, gcmd): raise gcmd.error("Unknown sensor '%s'" % (sensor_name,)) min_temp = gcmd.get_float("MINIMUM", float("-inf")) max_temp = gcmd.get_float("MAXIMUM", float("inf"), above=min_temp) + error_on_cancel = gcmd.get("ALLOW_CANCEL", None) is None if min_temp == float("-inf") and max_temp == float("inf"): raise gcmd.error( "Error on 'TEMPERATURE_WAIT': missing MINIMUM or MAXIMUM." @@ -1006,7 +1134,7 @@ def check(eventtime): gcmd.respond_raw(self._get_temp(eventtime)) return True - self.printer.wait_while(check) + self.printer.wait_while(check, error_on_cancel) def load_config(config): diff --git a/klippy/klippy.py b/klippy/klippy.py index 02110c7c6..5e770374f 100644 --- a/klippy/klippy.py +++ b/klippy/klippy.py @@ -53,6 +53,10 @@ """ +class WaitInterruption(gcode.CommandError): + pass + + class Printer: config_error = configfile.error command_error = gcode.CommandError @@ -386,7 +390,9 @@ def request_exit(self, result): self.run_result = result self.reactor.end() - def wait_while(self, condition_cb): + wait_interrupted = WaitInterruption + + def wait_while(self, condition_cb, error_on_cancel=True, interval=1.0): """ receives a callback waits until callback returns False @@ -397,8 +403,11 @@ def wait_while(self, condition_cb): eventtime = self.reactor.monotonic() while condition_cb(eventtime): if self.is_shutdown() or counter != gcode.get_interrupt_counter(): - return - eventtime = self.reactor.pause(eventtime + 1.0) + if error_on_cancel: + raise WaitInterruption("Command interrupted") + else: + return + eventtime = self.reactor.pause(eventtime + interval) ###################################################################### diff --git a/test/klippy/mpc.cfg b/test/klippy/mpc.cfg new file mode 100644 index 000000000..5fc29574e --- /dev/null +++ b/test/klippy/mpc.cfg @@ -0,0 +1,76 @@ +# Config for extruder testing +[stepper_x] +step_pin: PF0 +dir_pin: PF1 +enable_pin: !PD7 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PE5 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_y] +step_pin: PF6 +dir_pin: !PF7 +enable_pin: !PF2 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PJ1 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_z] +step_pin: PL3 +dir_pin: PL1 +enable_pin: !PK0 +microsteps: 16 +rotation_distance: 8 +endstop_pin: ^PD3 +position_endstop: 0.5 +position_max: 200 + +[fan] +pin: PD6 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.500 +filament_diameter: 3.500 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK5 +control: mpc +heater_power: 50 +cooling_fan: fan +filament_density: 1.20 +filament_heat_capacity: 1.8 +block_heat_capacity: 22.3110 +sensor_responsiveness: 0.0998635 +ambient_transfer: 0.155082 +fan_ambient_transfer: 0.155082, 0.20156, 0.216441 +min_temp: 0 +max_temp: 210 + +[extruder_stepper my_extra_stepper] +extruder: extruder +step_pin: PH5 +dir_pin: PH6 +enable_pin: !PB5 +microsteps: 16 +rotation_distance: 28.2 + +[mcu] +serial: /dev/ttyACM0 + +[printer] +kinematics: cartesian +max_velocity: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 diff --git a/test/klippy/mpc.test b/test/klippy/mpc.test new file mode 100644 index 000000000..0b9a297dc --- /dev/null +++ b/test/klippy/mpc.test @@ -0,0 +1,15 @@ +# mpc +DICTIONARY atmega2560.dict +CONFIG mpc.cfg + +# Extrude only +G1 E5 +G1 E-2 +G1 E7 + +# Home and extrusion moves +G28 +G1 X20 Y20 Z1 +G1 X25 Y25 E7.5 + +MPC_SET HEATER=extruder FILAMENT_DENSITY=1.15 FILAMENT_HEAT_CAPACITY=2.20