Z_thermal_adjust for nozzles

I put up a draft PR here: PR: Enable multiple z_thermal_adjust sections by garethky · Pull Request #6855 · Klipper3d/klipper · GitHub
I’ve been sitting on this code for the better part of a year. This came up when developing load cell based probing but I believe it should also help with any kind of nozzle probe, e.g. Voron Tap.

The Problem

Probing temperatures are limited based a couple of factors: The glass transition temp of the filament and the melting temperature of the bed surface. So probing is carried out at about 140-1670C. Printing temps for PC might be 290C. So there is, potentially a 150C temperature increase between probing and printing.

Investigations on my printer shows that the rate of thermal expansion for the nozzle is 0.000465mm per degree C. Over 150C that is 0.069mm / 69 microns or about 35% of the thickness of a first layer. The general result of this is first layers that are too low.

Proposed Solution

  • Extend z_thermal_adjust to support compensating for multiple components in the printer. So the Frame and the Nozzle can be compensated and the machine can adapt enclosed open conditions.

Example Config:

[z_thermal_adjust nozzle]
temp_coeff: -0.00046459
sensor_type: temperature_combined
sensor_list: extruder
combination_method: max
min_temp: 0
max_temp: 400
maximum_deviation: 1
max_z_adjustment: 0.5

[z_thermal_adjust frame]
gcode_id: ZFARME
temp_coeff: 0.0088
sensor_type: Generic 3950
sensor_pin: T1
min_temp: 0
max_temp: 120
max_z_adjustment: 1.0

I’ve been testing this in conjunction with load cell probing and the results have been very encouraging. The effect is temperature dependent and easy to measure. A GCode script can measure the nozzle length via probing over a range of temperatures and then produce the thermal coefficient through linear regression. I probed the aluminum bed surface directly with a clean nozzle and no filament loaded. For PCB beds you might have to put a steel surface on top, like the uncoated side of a steel sheet.

The real test is comparing first layers of materials at different temps. I’ve been testing swapping between PLA and PETG. With other variables removed (keeping the door open, using the same print surface) this lets me swap filaments and get very similar results between them.

Config

Typically for a nozzle youre looking at something like this:

[z_thermal_adjust nozzle]
temp_coeff: -0.00046459
sensor: extruder
max_z_adjustment: 0.5

The temp_coeff is negative. z_thermal_adjust has positive values indicate that the effective height of the printer is increasing, because it was designed for frame expansion. In this case the height of the printer should be decreasing as temperature increases.

Testing Macro

MEASURE_NOZZLE_Z_THERM_COEFF

This is a macro that can be used to find the thermal coefficient of nozzle expansion. It will sweep over a temperature range and use the PROBE command to measure the Z offset. It climes up to a MAX_TEMP and then back down, so we can see both thermal expansion and contraction. Then it uses linear regression to find the coefficient of expansion.

My suggestions for using this are to probe with no filament and a clean nozzle. Leave the enclosure open so the exterder doesn’t heat up the frame.

If this generally turns out to work well, we can look at turning this into a GCode Command.

# Measure the nozzle length over a temperature range and report the rate of chagne
[gcode_macro MEASURE_NOZZLE_Z_THERM_COEFF]
variable_temperatures: []
variable_z_values: []
gcode:
    SET_GCODE_VARIABLE MACRO=MEASURE_NOZZLE_Z_THERM_COEFF VARIABLE=z_values VALUE=[]
    {% set temperatures = [] %}

    # setup variables
    {% set MIN_TEMP = params.MIN_TEMP|default(140)|int %}
    {% set MAX_TEMP = params.MAX_TEMP|default(290)|int %}
    {% set SOAK_TIME = (params.SOAK_TIME|default(20.0)|float) * 1000.0 %}
    {% set INTERVAL = params.INTERVAL|default(10)|int %}
    {% set SAMPLES = params.SAMPLES|default(5)|int %}

    # measure temp going up ever INTERVAL degrees
    {% for EXTRUDER_TEMP in range(MIN_TEMP, MAX_TEMP, INTERVAL) %}
        M109 S{EXTRUDER_TEMP}
        G4 P{SOAK_TIME}
        {% set _ = temperatures.append(EXTRUDER_TEMP) %}
        _MEASURE_NOZZLE_Z_THERM_COEFF__MEASURE_Z SAMPLES={SAMPLES}
    {% endfor %}

    # measure temp going down every INTERVAL degrees
    {% for EXTRUDER_TEMP in range(MIN_TEMP, MAX_TEMP, INTERVAL)|reverse %}
        M109 S{EXTRUDER_TEMP}
        G4 P{SOAK_TIME}
        {% set _ = temperatures.append(EXTRUDER_TEMP) %}
        _MEASURE_NOZZLE_Z_THERM_COEFF__MEASURE_Z SAMPLES={SAMPLES}
    {% endfor %}

    # extruder to 0
    M104 S0

    SET_GCODE_VARIABLE MACRO=MEASURE_NOZZLE_Z_THERM_COEFF VARIABLE=temperatures VALUE="{temperatures | pprint | replace("\n", "")}"
    
    _MEASURE_NOZZLE_Z_THERM_COEFF__SHOW_RESULTS


[gcode_macro _MEASURE_NOZZLE_Z_THERM_COEFF__SHOW_RESULTS]
gcode:
    {% set measure = printer['gcode_macro MEASURE_NOZZLE_Z_THERM_COEFF'] %}
    {% set temperatures = measure.temperatures %}
    {% set z_values = measure.z_values %}

    # debug logging
    {action_respond_info("Temperatures: %s" % (temperatures | pprint | string |replace("\n", "")))}
    {action_respond_info("Z Values: %s" % (z_values | pprint | string | replace("\n", "")))}

    # linear regression
    {% set avg_temp = (temperatures | sum) / (temperatures | length) %}
    {% set avg_z = (z_values | sum) / (z_values | length) %}
    {% set a = [] %}
    {% set b = [] %}
    {% for temp in temperatures %}
        {% set _ = a.append(temp * (z_values[loop.index - 1] - avg_z)) %}
        {% set _ = b.append(temp * (temp - avg_temp)) %}
    {% endfor %}
    {% set a_sum = a | sum %}
    {% set b_sum = b | sum %}
    {% set temp_coeff = ((a_sum / b_sum) * -1.0) | round(8) %}
    # tell the user what the setting is:
    {action_respond_info("Temperature Coefficient: %s" % (temp_coeff))}

[gcode_macro _MEASURE_NOZZLE_Z_THERM_COEFF__MEASURE_Z]
gcode:
    {% set SAMPLES = params.SAMPLES|default(5)|int %}
    PROBE SAMPLES={SAMPLES}  # take 5 probe samples
    # Dragons:
    # A move needs to be issued so the kinematics get updated position info!!
    # without this move its always off by the length of the pullback move!!
    G1 Z5 F{5 * 60}  # lift to 5mm
    M400
    _MEASURE_NOZZLE_Z_THERM_COEFF__SAVE_Z # has to be a sub-macro call to see probing results

# this call save the result of the last probe and the current temperature to two arrays
[gcode_macro _MEASURE_NOZZLE_Z_THERM_COEFF__SAVE_Z]
gcode:
    {% set measure = printer['gcode_macro MEASURE_NOZZLE_Z_THERM_COEFF'] %}
    {% set z_values = measure.z_values %}
    {% set z_probed = printer.probe.last_z_result %}  # absolute position of the probe
    {% set _ = z_values.append(z_probed) %}

    # debug
    #{action_respond_info("Z Values: %s" % (z_values | pprint | string | replace("\n", "")))}
    
    SET_GCODE_VARIABLE MACRO=MEASURE_NOZZLE_Z_THERM_COEFF VARIABLE=z_values VALUE="{z_values | pprint | replace("\n", "")}"

This is one of those “I’ve pushed Jinja/klipper too far” type macros. If you see bugs, let me know about it! I’ll update this post with fixes.

Hey Gareth,

Interesting idea and something that I think should be investigated more.

Now, I just tried your macro on one of my custom CoreXY machines with a clean clone Revo 6 Nozzle with no filament:

https://www.aliexpress.com/item/1005007775240144.html

The Z axis sensor is an inductive probe.

I ran from 40C to 240C with the soak time of “8” (this was determined after watching how long things took to go from 40 to 240 as well as 240 to 40 and stabilize) the default interval of “10” and “8” samples.

Before running, I homed the toolhead and put it at the centre of the build surface.

Here are the results:

1:42 PM Temperature Coefficient: -1.8332941729547998e-05
1:42 PM Z Values: [1.009949999987414, 1.004481249987879, 1.0015124999883507, 0.9955749999888284, 0.9916687499893116, 0.9888562499897954, 0.9874499999902812, 0.9868249999907681, 0.9874499999912545, 0.9886999999917405, 0.9918249999922235, 0.9901062499927074, 0.9911999999931909, 0.9905749999936749, 0.9929187499941572, 0.9972937499946363, 1.006356249995106, 1.009949999995569, 1.0140124999960243, 1.0136999999964758, 1.0076062499969343, 1.004012499997402, 0.9986999999978757, 0.9961999999983528, 0.9946374999988317, 0.9944812499993105, 0.9943249999997892, 0.9947937500002684, 0.9951062500007473, 0.9933875000012271, 0.993387500001707, 0.9927625000021865, 0.991356250002668, 0.9982312500031442, 1.0037000000036151, 1.0068250000040813, 1.0066687500045448, 1.0040125000050113, 0.9996375000054845, 0.9972937500059598]
1:42 PM Temperatures: [40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 230, 220, 210, 200, 190, 180, 170, 160, 150, 140, 130, 120, 110, 100, 90, 80, 70, 60, 50, 40]

Could you please explain the theory behind this?

I was expecting something in which I would set the Z height of the nozzle above the bed (using something like the paper test, although I’m using feeler gauges now) at different temperatures to get the actual changes in the length of the nozzle/hotend assembly at different temperatures.

It seems like this is checking the differences of the Z Axis sensor at different nozzle temperatures.

1 Like

This was designed around nozzle contact probes, like Voron Tap or Load Cells. So the idea is that you are directly measuring the nozzle length when probing.

If you use an inductive probe and there is not contact with the bed, they you are measuring the thermal effects on the inductive probe. I suppose this could work? I haven’t experimented with it.

I guess I need to do some rounding, doesn’t look like you have much of an expansion coefficient there:

-1.8332941729547998e-05 == -0.00001833

I would hope there isn’t much of an expansion coefficient as there is no physical connection between the probe and the nozzle/heater and a few millimetres of space.

I am curious to see what the expansion coefficient of the actual nozzle/hot end is.

Let me think on it and let me know if you have any comments.

So your data looks like this:

Its not clear there is any correlation with temperature there.

This is the data from my printer with the load cell:

This shows clear correlation between temperature and nozzle length.

// Temperatures: [140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240, 250, 260, 270, 280, 280, 270, 260, 250, 240, 230, 220, 210, 200, 190, 180, 170, 160, 150, 140]
// Z Values: [-0.27149145865894964, -0.2708356679523388, -0.26884499147864593, -0.26581442907340347, -0.2624353999768732, -0.2581173841658145, -0.25283719566199664, -0.24701738410998536, -0.2407337635551256, -0.2354650587644202, -0.2295765791868623, -0.22338734413501998, -0.21669963277021806, -0.20886194591847876, -0.20214578531829425, -0.19983433537033776, -0.20275253479320704, -0.20868005618877955, -0.21499507084963806, -0.22151004927758508, -0.2272930169374617, -0.23342532909454014, -0.2409402094251766, -0.24646451679397127, -0.2527064744518238, -0.2585848408290665, -0.26422514060408026, -0.26843950924638343, -0.27385338188754477, -0.27920006660065566]
// Temperature Coefficient: -0.00055297

So lets take an example of PLA vs PC. Maybe you print your PLA at 200 and your PC at 280. Over 80C there is a nozzle length change of 0.044mm. From the probed length at 140 to 280 its 0.077mm. Errors that large are easy to see.

2 Likes

You might ask if this somehow is an effect of the temperature of the load cell. The load cell isn’t a proximity sensor. No absolute value of the senors output is used to decide position. The Z coordinate is decided by the change in the rate of change of the resistance driven by continuous motion. That change might happen at a different absolute level, but that is not relevant to the position. So this has to be “real”.

Also trying printing tests with the compensation turned on vs turns off shows the results you would expect. Its too low at high temps.

With this analysis, I have a couple of comments.

The first is:

When I looked at this graph, two things jumped out at me and I’ve marked them. The first is that there is a quite “Stable Region” between 100C and 200C where things stay pretty stable. The second is the fairly linear increase at 200C+.

Now, I would expect that if these were real characteristics of the inductive sensor then I could reproduce them in other ways - to test this hypothesis, I ran a repeatability check on the printer from 22C to 300C with the bed at ambient temperature (22C) and at 65C (my normal operating temperature).

I don’t see any decrease in sense range or std. deviation between 100C and 200C, nor do I see an increase from 200C+. I reran your macro and got different results, with the range being around 20μm to 25μm, like you have in your graph.

So, while in the case where there is one sample set, there appears to be some areas to look at, when tried some other areas to understand the data, there doesn’t seem to be anything worth pursuing.


The second comment I want to make is that using feeler gauges to measure the heat expansion of the nozzle, I came up with the value of 0.00068mm/C (Change in distance from the build surface to the nozzle over change in temperature) which is of the same order of magnitude as what you have in the original post.

What concerns me is that when I look up the value for brass heat expansion:

These values are an order of magnitude more than we should expect.

Can you explain this discrepancy?

I think your reading of the plot is good. These inductive probes have sensitivity to temperature. Some have a temperature sensor output and some have internal compensation. Perhaps this one is internally compensated and we can see the sort of range over which it compensates well.


We are measuring the total elongation of the nozzle + heatbreak system. The heatbreak is cooled but its temperature goes up with nozzle temperature. This is the reason I included the additional 30s of soak time. It takes some time for the heatbreak to get up to temp or cool off for each measurement.

If I consider all the parts of that system, their size and materials I get this theoretical number: 0.00059768mm/C

This is very very close to what we are measuring empirically with either method

2 Likes

Ratrig (via RatOS)has had a macro for a while now that does this type of probe and compensation. There are 150C and 250C probes to determine the expansion coefficient.

Most people, with accurate probes and no Z issues, find it very effective. We have not done any work to try to collect the actual compensation values for a default expansion recommendation. Instead we recommend that everyone do it for each machine and temperature range.

Here is a Beacon centric macro for klipper we developed for people using Beacon contact feature for Z probing. BeaconPrinterTools/Tools/Thermal_Expansion_Compensation/Thermal_expansion_compensation.md at main · YanceyA/BeaconPrinterTools · GitHub

1 Like