2026-10-10 14:24:37 +02:00
# HEAT_SOAK: wait for a sensor to reach thermal equilibrium (community macro)
2026-10-10 13:45:11 +02:00
##
## HEAT_SOAK HEATER=<heater_name> TARGET=<target_heater_temperature> SOAKER=<heat_soak_temp_sensor_name> [RATE=<heat_soak_temp_rate_of_change>] [TIMEOUT=<time_to_abort_heat_soak>]
##
##
## e.g.
## HEAT_SOAK HEATER=heater_bed TARGET=100 SOAKER="temperature_sensor top_bed"
##
## Based on work by blalor: https://klipper.discourse.group/t/interruptible-heat-soak/1552
##
## Modified by me
##
[gcode_macro HEAT_SOAK]
description: Wait for a temperature sensor to reach thermal equilibrium
variable_last_temp: 0
variable_last_soak_temp_rate: None
variable_temp_history: None
variable_smoothed_temp_history: None
variable_stage: "done" ## heating -> soaking -> done -> None
variable_total_time_elapsed: 0
variable_soak_time_remaining: 30
variable_check_interval: 1
variable_heating_report_interval: 2
variable_soaking_report_interval: 5
variable_heater_sensor: None
variable_soaker_sensor: None
variable_target_temp: 0.0
variable_min_soak_temp: 0
variable_target_rate: 0.3
variable_complete: None
variable_cancel: None
variable_temp_smooth_time: 4.0
variable_rate_smooth_time: 20.0
variable_resume_trigger: False
variable_was_print_active: False
gcode:
{ action_respond_info( "Heat Soak starting" )}
# Soaker is required
{% set SOAKER = params.SOAKER | string %}
{% set RATE = params.RATE | default(0.3) | float %} ## in degrees C per minute
{% set SOAK_TEMP = params.SOAK_TEMP | default(0.0) | float %}
{% set HEATER = (params.HEATER | string) %} # Optional heater
{% set TARGET = params.TARGET | default(0) | float %}
{% set TIMEOUT = (params.TIMEOUT | default(30) | int) * 60 %} ## minutes to seconds
{% set TEMP_SMOOTH = params.TEMP_SMOOTH | default(4.0) | float %} ## seconds for temp smoothing
{% set RATE_SMOOTH = params.RATE_SMOOTH | default(20) | float %} ## seconds for rate smoothing
{% set COMPLETE = (params.COMPLETE | string) %}
{% set CANCEL = (params.CANCEL | string) %}
{% set HEATING_REPORT_INTERVAL = params.HEATING_REPORT_INTERVAL | default(2) | int %}
{% set SOAKING_REPORT_INTERVAL = params.SOAKING_REPORT_INTERVAL | default(5) | int %}
# User writeable variables
SET_GCODE_VARIABLE MACRO = HEAT_SOAK VARIABLE=heater_sensor VALUE="{HEATER | pprint}"
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=target_temp VALUE={TARGET}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=min_soak_temp VALUE={SOAK_TEMP}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soaker_sensor VALUE="{SOAKER | pprint}"
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=target_rate VALUE={RATE}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE={TIMEOUT}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=complete VALUE="'{COMPLETE}'"
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=cancel VALUE="'{CANCEL}'"
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=temp_smooth_time VALUE={TEMP_SMOOTH}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=rate_smooth_time VALUE={RATE_SMOOTH}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=heating_report_interval VALUE={HEATING_REPORT_INTERVAL}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soaking_report_interval VALUE={SOAKING_REPORT_INTERVAL}
# Internal variables
{% set soak_temp = printer[SOAKER].temperature %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=last_temp VALUE={soak_temp}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=temp_history VALUE="{[soak_temp] | pprint}"
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=smoothed_temp_history VALUE="{[] | pprint}"
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed VALUE=0
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=last_soak_temp_rate VALUE=None
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=check_interval VALUE=1.0
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=resume_trigger VALUE=False
{% set is_print_active = printer['virtual_sdcard'].is_active or printer['virtual_sdcard'].file_position != 0.0 %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=was_print_active VALUE={is_print_active}
# start optional heater
{% if (HEATER and TARGET ! = 0.0) %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage VALUE="'heating'"
SET_HEATER_TEMPERATURE HEATER={HEATER} TARGET={TARGET}
{% else %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage VALUE="'soaking'"
{% endif %}
# pause the print, if active
{% if is_print_active %}
PAUSE_BASE
{% endif %}
UPDATE_DELAYED_GCODE ID = _heat_soaker DURATION={check_interval}
[gcode_macro STOP_HEAT_SOAK]
description: stops heat soak activity without running any callbacks
gcode:
UPDATE_DELAYED_GCODE ID = _heat_soaker DURATION=0 # cancel any pending run
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage VALUE="'done'"
[gcode_macro CANCEL_HEAT_SOAK]
description: cancels an in-progress HEAT_SOAK cycle and runs the cancel callback
gcode:
# check that the HEAT_SOAK macro is active before canceling
{% set heat_soak = printer['gcode_macro HEAT_SOAK'] %}
{% set stage = heat_soak.stage %}
{% if stage in ("heating", "soaking") %}
STOP_HEAT_SOAK
{% if heat_soak.cancel %}
{heat_soak.cancel}
{% endif %}
{% endif %}
[gcode_macro HEAT_SOAK_RESUME]
description: Resume while heat soaking results in the soaking phase being skipped, any complete callback is run
gcode:
# {% set ON_RESUME = (params.ON_RESUME | default("_HEAT_SOAK__BASE_RESUME")) | string %}
{% set ON_RESUME = (params.ON_RESUME | default("_HEAT_SOAK__BASE_RESUME")) | string %}
{% set heat_soak = printer['gcode_macro HEAT_SOAK'] %}
{% set stage = heat_soak.stage %}
{% if stage == "heating" %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=resume_trigger VALUE=True
{% elif stage == "soaking" %}
STOP_HEAT_SOAK
{% if heat_soak.complete %}
{heat_soak.complete}
{% endif %}
{ON_RESUME} {% for p in params %}{'%s=%s ' % (p, params[p])}{% endfor %}
{% else %}
{ON_RESUME} {% for p in params %}{'%s=%s ' % (p, params[p])}{% endfor %}
{% endif %}
[gcode_macro RESUME]
description: Resume while heat soaking results in the soaking phase being skipped, any complete callback is run
rename_existing: _HEAT_SOAK__BASE_RESUME
gcode:
HEAT_SOAK_RESUME ON_RESUME = _HEAT_SOAK__BASE_RESUME
[delayed_gcode _heat_soaker]
# description: internal macro to handle heat soaking activity
gcode:
{% set heat_soak = printer['gcode_macro HEAT_SOAK'] %}
# debug
#{ action_respond_info( printer['gcode_macro HEAT_SOAK'] | tojson )}
# shut down cleanly if we detect that the user canceled or started a print while heat soaking
{% set file_position_is_zero = (printer['virtual_sdcard'].file_position == 0.0 )%}
{% if heat_soak.was_print_active and file_position_is_zero %}
STOP_HEAT_SOAK
{action_respond_info("HEAT_SOAK aborted. Detected a change in virtual_sdcard print state. Call STOP_HEAT_SOAK from CANCEL_PRINT to stop seeing this error message.")}
{% endif %}
# shut down cleanly if we detect that the user resumed the print while heat soaking
{% if not file_position_is_zero and not printer['pause_resume'].is_paused %}
STOP_HEAT_SOAK
{action_respond_info("HEAT_SOAK aborted. Print is no longer paused. Call STOP_HEAT_SOAK or HEAT_SOAK_RESUME from your RESUME macro to stop seeing this error message.")}
{% endif %}
# update total time elapsed
{% set total_time_elapsed = heat_soak.total_time_elapsed + heat_soak.check_interval %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed VALUE={ total_time_elapsed }
{% set soaker_sensor = printer[heat_soak.soaker_sensor] %}
{% set soak_temp = soaker_sensor.temperature %}
{% if heat_soak.heater_sensor %}
{% set heater_sensor = printer[heat_soak.heater_sensor] %}
{% set heater_temp = heater_sensor.temperature | round(1) %}
{% endif %}
{% set stage = heat_soak.stage %}
# compute the new temp difference and save history
{% set temp_history = heat_soak.temp_history %}
{% set smoothed_temp_history = heat_soak.smoothed_temp_history %}
{% set temp_diff = soak_temp - (temp_history | last) %}
{% set _ = temp_history.append(soak_temp) %}
{% set soak_temp_smoothed = None %}
{% set slope = None %}
{% if (temp_history | length) > heat_soak.temp_smooth_time %}
{% set temp_history = temp_history[1:] %}
# compute average temp
{% set soak_temp_smoothed = ((temp_history | sum) / (temp_history | length)) %}
# save raw value to history
{% set _ = smoothed_temp_history.append(soak_temp_smoothed) %}
# round off for display and comparisons later
{% set soak_temp_smoothed = soak_temp_smoothed | round(3) %}
# compute the slope
{% if (smoothed_temp_history | length) > heat_soak.rate_smooth_time %}
{% set smoothed_temp_history = smoothed_temp_history[1:] %}
# Least Squares
# adapted from: https://towardsdatascience.com/linear-regression-using-least-squares-a4c3456e8570
# X values are time in seconds
# Y values are temperatures
{% set count = (smoothed_temp_history | length) %}
{% set times = range(0, count, 1) %}
{% set x_sum = (times | sum) | float %}
{% set y_sum = (smoothed_temp_history | sum) | float %}
# Squares
# Jinja oddity: += doesn't work in loops??, so collect squares and sum after
{% set xx_sum_arr = [] %}
{% set xy_sum_arr = [] %}
{% for i in times %}
{% set x = times[i] %}
{% set y = smoothed_temp_history[i] %}
{% set _ = xx_sum_arr.append(x * x) %}
{% set _ = xy_sum_arr.append(x * y) %}
{% endfor %}
{% set xx_sum = (xx_sum_arr | sum) | float %}
{% set xy_sum = (xy_sum_arr | sum) | float %}
# Slope calculation. Slope is per second so * 60 for rate per minute
{% set slope = (60.0 * ((count | float * xy_sum) - (x_sum * y_sum)) / ((count | float * xx_sum) - (x_sum * x_sum))) | round(3) %}
{% endif %}
{% endif %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=temp_history VALUE="{(temp_history | pprint | replace("\n", ""))}"
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=smoothed_temp_history VALUE="{(smoothed_temp_history | pprint | replace("\n", ""))}"
# while heating
{% if stage = = "heating" %}
{% if heater_temp < heat_soak.target_temp %}
{% if total_time_elapsed % heat_soak.heating_report_interval == 0 %}
{% set message = "Heating -- %.1fC / %.1fC -- %.1fm elapsed" % (heater_temp, heat_soak.target_temp, total_time_elapsed / 60.0) %}
M117 {message}
{action_respond_info(message)}
{% endif %}
{% else %}
{action_respond_info("Heating completed after ~%.1fm, starting heat soak phase." % (total_time_elapsed / 60.0))}
# skip the soak phase if resume was pressed
{% set stage = "done" if heat_soak.resume_trigger else "soaking" %}
# reset total time to 0 so soaking time is reported from 0
{% set total_time_elapsed = 0 %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed VALUE={ total_time_elapsed }
{% endif %}
{% endif %}
# while soaking
{% if stage = = "soaking" %}
## decrement the soak countdown
{% set soak_time_remaining = [heat_soak.soak_time_remaining - heat_soak.check_interval, 0] | max %}
SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE={ soak_time_remaining }
## abort soaking if timeout is reached
{% if soak_time_remaining < = 0 %}
{action_respond_info("Heat soak timed out after ~%.1fm" % (total_time_elapsed / 60.0))}
{% set stage = "cancel" %}
# if the smoothing algorithm hasn't settled on these values yet, dont evaluate them
{% elif slope is none or soak_temp_smoothed is none %}
{% if total_time_elapsed % heat_soak.soaking_report_interval == 0 %}
## not enough data points yet to determing the soak rate, show dashes
{% set message = "Soaking -- gathering data -- %.1fm remaining" % (soak_time_remaining / 60.0) %}
M117 { message }
{action_respond_info(message)}
{% endif %}
# if the heat soak must continue for some reason
{% elif (slope > heat_soak.target_rate) or (heat_soak.min_soak_temp > 0 and soak_temp_smoothed < heat_soak.min_soak_temp) %}
{% if total_time_elapsed % heat_soak.soaking_report_interval == 0 %}
{% set format_vars = [
soak_temp_smoothed | float,
(heat_soak.min_soak_temp | float | round(0) | string) if (heat_soak.min_soak_temp > 0) else "--",
slope,
heat_soak.target_rate,
soak_time_remaining / 60.0
] %}
{% set message = "Soaking -- temp: {0:.1f}C / {1}C -- rate: {2:.3f}C/m / {3:.3f}C/m -- {4:.1f}m remaining".format(*format_vars) %}
M117 { message }
{action_respond_info(message)}
{% endif %}
## end soaking if the target rate is achieved
{% else %}
{action_respond_info("Heat soak complete after ~%.1fm at %.3fC/m / %.1fC" % (total_time_elapsed / 60.0, slope, soak_temp))}
{% set stage = "done" %}
{% endif %}
{% endif %}
# save stage before calling any macros
SET_GCODE_VARIABLE MACRO = HEAT_SOAK VARIABLE=stage VALUE="'{stage}'"
{% if stage in ("done", "cancel") %}
STOP_HEAT_SOAK
{% if stage = = "cancel" %}
{% if heat_soak.cancel %}
{heat_soak.cancel}
{% elif printer['virtual_sdcard'].is_active %}
CANCEL_PRINT
{% endif %}
{% else %}
{% if heat_soak.complete %}
{heat_soak.complete}
{% endif %}
{% if printer['pause_resume'].is_paused %}
RESUME
{% endif %}
{% endif %}
{% else %}
## trigger ourselves again
UPDATE_DELAYED_GCODE ID=_heat_soaker DURATION={ heat_soak.check_interval }
## dwell for 1ms to prevent from going idle
G4 P1
{% endif %}
# save ending value of stage
2026-10-10 14:24:37 +02:00
SET_GCODE_VARIABLE MACRO = HEAT_SOAK VARIABLE=stage VALUE="'{stage}'"