# Strain Gauge/Load Cell based Endstops

**URL:** <https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134>\
**Category:** Developers\
**Created:** [February 23, 2022, 5:15am UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134 "2022-02-23T05:15:09Z")\
**Posts on this page:** 17\
**Page:** 33

<div class="post-metadata">

**Author:** ![koconnor](https://avatars.discourse-cdn.com/v4/letter/k/aca169/32.png) [@koconnor](https://klipper.discourse.group/u/koconnor)\
**Post date:** [June 14, 2026, 3:24am UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/654 "2026-06-14T03:24:18Z")

</div>

> [@mhier](#):
>
> The data for the load cell is kind of “inverse” compared to the eddy current, as it will be flat without contact and change linear with contact.

Right.

> [@mhier](#):
>
> I still think the math can be a lot simpler, because it is linear and no complicated calibration is required.

Right - it should be simpler. That said, I do think it would be worthwhile to look into a similar “non-linear least squares” system for finding the contact point.

I made a change to the upstream code to move the linear squares code out of the probe\_eddy\_current `EddyTap()` class and into a new `TapBestFit()` class (commit ccdb7717). Hopefully this will make that code a little easier to understand in the future.

For what it is worth, I’d guess a “best fit” mechanism for load-cell would look something like the code below. This is totally untested, but maybe it gives some ideas. This sample code also does not implement least squares caching, but that could be added later if desired. (Caching improves the algorithm from O(n\*n) complexity to O(n) complexity.)

Cheers,  
-Kevin

```python
# Given a list of (grams, z) pairs, find the coefficients z_contact,
# grams_contact, depress_slope, slope that best fit the data to the
# formulas `grams = grams_contact + depress_slope*(z-z_contact)` when
# z<=z_contact and `grams = grams_contact + slope*(z-z_contact)` when
# z>=z_contact. This implements a form of non-linear least squares.
class LCBestFit:
    def _calc_least_squares(self, samples, est_z_contact):
        len_samples = len(samples)
        eqs = [[0.] * 3 for i in range(len_samples)]
        ans = [[0.] for i in range(len_samples)]
        for i, (step_z, sensor_grams) in enumerate(samples):
            a = ans[i]
            eq = eqs[i]
            if step_z <= est_z_contact:
                # 1*c0 + (z-ezc)*c1 = grams
                eq[0] = 1.
                eq[1] = step_z - est_z_contact
                a[0] = sensor_grams
            else:
                # 1*c0 + (z-ezc)*c2 = grams
                eq[0] = 1.
                eq[1] = step_z - est_z_contact
                a[0] = sensor_grams
        eqst = mathutil.mat_transp(eqs)
        eqst_eqs = mathutil.mat_mat_mul(eqst, eqs)
        eqst_ans = mathutil.mat_mat_mul(eqst, ans)
        coeffs = mathutil.gaussian_solve(eqst_eqs, eqst_ans)
        if coeffs is None:
            return sys.float_info.max, [[0.]] * 3
        rel_err = -sum([c[0]*a[0] for c, a in zip(coeffs, eqst_ans)])
        return rel_err, coeffs
    def find_best_fit(self, data):
        #for d in data:
        # logging.info("sample: grams=%.3f z=%.6f", d[0], d[1][2])
        # Change base of grams/z measurements to improve numerical stability
        base_z = .5 * (data[0][1][2] + data[-1][1][2])
        base_grams = .5 * (data[0][0] + data[-1][0])
        samples = [(d[1][2] - base_z, d[0] - base_grams) for d in data]
        # Run least squares with various z values to reduce residual error
        min_z = best_z = samples[0][0]
        max_z = samples[-1][0]
        best_err = sys.float_info.max
        best_coeffs = [0., 0., 0.]
        while max_z - min_z > 0.000050:
            # Select z value to check
            mid_z = (min_z + max_z) * .5
            if best_z < mid_z:
                guess_z = (best_z + max_z) * .5
            else:
                guess_z = (min_z + best_z) * .5
            # Calculate least squares error for given z
            guess_err, coeffs = self._calc_least_squares(samples, guess_z)
            # Update search bounds
            if guess_err < best_err:
                if guess_z > best_z:
                    min_z = best_z
                else:
                    max_z = best_z
                best_z = guess_z
                best_err = guess_err
                best_coeffs = coeffs
            else:
                if guess_z > best_z:
                    max_z = guess_z
                else:
                    min_z = guess_z
        # Return to original grams/z measurement base
        bc = [v[0] for v in best_coeffs]
        z_contact = base_z + best_z
        grams_contact = base_grams + bc[0]
        depress_slope = bc[1]
        slope = bc[2]
        #logging.info("probe_analysis: coeffs=%s",
        # (z_contact, grams_contact, depress_slope, slope))
        return z_contact, grams_contact, depress_slope, slope

```

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [June 14, 2026, 8:40am UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/655 "2026-06-14T08:40:29Z")

</div>

> [@koconnor](#):
>
> That said, I do think it would be worthwhile to look into a similar “non-linear least squares” system for finding the contact point.

What will be the advantage? The relationship is physically speaking linear. I wouldn’t know which non-linear function to fit. With my printer, the function is perfectly linear. If the measurement is sane, I get an R of around 99%. Adding more parameters will also require more data points, which in turn will require scanning at a lower speed. Hence I would rather stay with a linear function. (And of course the linear fit is computationally a lot less complex, since we have a closed form and do not need iterations.)

---

<div class="post-metadata">

**Author:** ![koconnor](https://avatars.discourse-cdn.com/v4/letter/k/aca169/32.png) [@koconnor](https://klipper.discourse.group/u/koconnor)\
**Post date:** [June 14, 2026, 3:43pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/656 "2026-06-14T15:43:26Z")

</div>

> [@mhier](#):
>
> The relationship is physically speaking linear. I wouldn’t know which non-linear function to fit. With my printer, the function is perfectly linear.

I guess I’m not understanding how you are planning to determine the nozzle/bed contact point.

The way it is done on eddy “tap” is by analyzing the lift move - we collect samples during the lift and from that we gather a series of `(frequency, z)` pairs. We can then analyze that to determine the point where the nozzle breaks contact with the bed.

I’d guess the same lift system could be used on loadcell. In that case, the lifting data should contain two linear segments and the goal is to find the intersection of those two linear segments to determine the point where the nozzle/bed break contact. When the nozzle is in contact with the bed there should be a strong linear relationship between pressure and stepper z position, and after the nozzle breaks contact with the bed there should be a different linear relationship between pressure and stepper z position that is nearly zero (but not entirely zero due to other forces on the toolhead, such as bowden tube pressure). Finding the interesection of those two linear segments is a non-linear least squares problem. Finding the coefficients (slopes and intersection pressure) if one knows the position of contact is an ordinary least squares problem, but since that position is not known the problem as a whole is non-linear.

I guess I’m not sure what your plan to find the contact point is.

Cheers,  
-Kevin

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [June 14, 2026, 7:16pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/657 "2026-06-14T19:16:27Z")

</div>

Back then, when I tried to get my old implementation merged into Klipper, I wrote some explanation, which is in part still valid:

> <https://github.com/RF1000community/klipper/blob/wip-loadcellprobe/docs/RF1000LoadCellProbe.md#the-fit>

We no longer need do to this crazy “drift compensation”, which was necessary because the implementation was purely in Python and hence we had to stop the movement for each data point. This was that slow, that the baseline might significantly wander away unpredictable.

Now, with @garethky 's implementation, we can take measurement data while moving. Hence we can assume that the baseline is stable enough during the measurement.

So, yes you are right, we have two linear sections. In the first section, where the nozzle is in contact with the bed, we have a strong dependency of the force on the z position. In the second section, we have no dependency at all. So that section is not only linear, it is ideally constant. You can see that in the graph shown in the above link. We should use the average value of that section as an updated baseline (tare compensation). In the first, truly linear section, we can make a simple linear regression after subtracting the baseline. We can do the fit with basically the axes swapped, so we fit z = m\*F + b, with F the (tare-compensated) force, z the z coordinate, and m and b the fit parameters. The point of contact is exactly where the force is zero, hence b will be our z position and can be directly used for the probe result.

I now realise that because the axis is moving while measuring, we have a systematic error of about one sampling period (reap. the distance the axis has traveled in that time). This may depend a bit on the ADC. Most ADCs will “freeze” the analogue value at the beginning of the conversion period (sample&hold) and then perform the conversion. Since we attach the timestamp when we receive the value, i. e. when the conversion is done, this will be off by roughly one sampling period. This is probably the reason for the small systematic error I had seen in my recent tests.

I hope I can do further tests tomorrow evening (European time)…

---

<div class="post-metadata">

**Author:** ![koconnor](https://avatars.discourse-cdn.com/v4/letter/k/aca169/32.png) [@koconnor](https://klipper.discourse.group/u/koconnor)\
**Post date:** [June 14, 2026, 9:15pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/658 "2026-06-14T21:15:03Z")

</div>

> [@mhier](#):
>
> The point of contact is exactly where the force is zero, hence b will be our z position and can be directly used for the probe result.

Ah, okay. I think I understand what you are proposing. Basically, estimate the sensor force when the nozzle is slightly above the bed (the sensor “zero” value), then find the measurements where the nozzle is definitely pressed into the bed, fit those pressed measurements to a line, and then find the Z position where that line would intersect with the previously found “zero” value.

For what it is worth though, I think you’ll get better results with the mechanism I described in my previous post.

I’d be concerned that errors in the “zero” position estimate could skew the final results. You’d also potentially be discarding samples very near the contact point for fear of not knowing if they are pre/post contact. The errors would be small (think microns), but the desired precision of the probe is very tiny (also think microns).

The looping in the `LCBestFit` code I posted earlier could be thought of as simultaneously reducing errors in both the “depressed linear segment” and the “zero” value. It’s a little more math, but I suspect it’ll produce better results. It also doesn’t require any taring or calibration - one can pass in the raw ADC values. That is, it should find the “elbow” in the data without external assumptions and without pre-filtering.

> [@mhier](#):
>
> Most ADCs will “freeze” the analogue value at the beginning of the conversion period (sample&hold) and then perform the conversion. Since we attach the timestamp when we receive the value, i. e. when the conversion is done, this will be off by roughly one sampling period.

FYI, the sensor timestamps should already account for this delay. The details are in `bulk_sensor.FixedFreqReader._update_clock()`.

Cheers,  
-Kevin

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [June 17, 2026, 7:50pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/659 "2026-06-17T19:50:45Z")

</div>

Ok, I understand your idea now. The idea of fitting both sections simultaneously is charming indeed, even if it is numerically more complex.

I have actually tried it out already: [klipper/klippy/extras/load\_cell\_probe.py at lcp-linear-fit · RF1000community/klipper · GitHub](https://github.com/RF1000community/klipper/blob/lcp-linear-fit/klippy/extras/load_cell_probe.py)

Unfortunately the results look actually a bit worse so far, compared to my original implementation with the linear fit:

```auto
// probe accuracy results: maximum -0.015958, minimum -0.032384, range 0.016426, average -0.023593, median -0.024679, standard deviation 0.005763

```

```auto
// probe accuracy results: maximum 0.000192, minimum -0.028547, range 0.028738, average -0.019952, median -0.023366, standard deviation 0.009251

```

(Speed 2 again)

Maybe there is still some stupid mistake in there though, I will continue investigating.

What I have also found is that the systematic error is now in the opposite direction (same size). This fuels my suspicion that there is some kind of timing problem when associating the measured forces with the z position. I will also look into this more carefully.

EDIT: Yes, I am stupid. The speed is wrong, since the lift speed is relevant… 🤣

EDIT2: Still even with the ascend speed changed to 2 I get worse results. I guess I have to output the data and analyse offline what is going on…

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [June 18, 2026, 4:10pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/660 "2026-06-18T16:10:42Z")

</div>

I found the problem. I had some samples in the fit data from before starting the ascend (but after the descend stopped), which was slightly off (maybe partially because of those oscillations from hitting the bed, partially because of base line wandering - there is a short pause before starting the ascend). Also maybe having so many data points in one place (far away from the interesting region) isn’t ideal for the fit.

Now with this I get at least comparable results with my original approach:

> // probe accuracy results: maximum -0.011378, minimum -0.019403, range 0.008025, average -0.015511, median -0.015077, standard deviation 0.002673

> // probe accuracy results: maximum -0.007944, minimum -0.022350, range 0.014407, average -0.014715, median -0.014398, standard deviation 0.004457

I also removed the free parameter for the slope during the second section of the fit (without bed contact), but this does not change anything.

Finally, I am thinking: We can simplify this approach drastically. Currently, we vary the split point between the two sections iteratively with a fine granularity until we find the best fit, and for each iteration we compute a best fit of that piecewise function to the data. There should not be any difference in the result if we do the following approach:

1. Iterate the split point only with a granularity of samples.
2. For each iteration, fit both sections separately with linear regression resp. just take the average for the tare.
3. Compute the square residual error for each iteration and pick the best iteration
4. Compute the intersection of the two sections for the best iteration. The probe result is the z-position of that intersection.

Since we can easily estimate the split point with an accuracy of 2-3 samples, we just have to do 2-3 iterations. This will be a lot faster and might even be numerically more stable.

I think this approach should be mathematically equivalent (except the negligible remaining error due to the finite number of iterations), since the iterative approach also assigns each sample to either section. The square residual error is a sum for each data point, so the total residual error is minimal when the residual error is minimal for both sections. Linear regression and computing the average also minimises the residual error, hence we should end up at the same result.

Am I overlooking something?

I like your approach in general since it eliminates the “fishy” part to guess which sample should belong to what section. If that goes wrong occasionally it can make the probe result less stable and might lead to false “bumps” in the mesh which are not actually there. That alone is enough of an argument to try this - the precision is already good enough I think…

---

<div class="post-metadata">

**Author:** ![koconnor](https://avatars.discourse-cdn.com/v4/letter/k/aca169/32.png) [@koconnor](https://klipper.discourse.group/u/koconnor)\
**Post date:** [June 18, 2026, 5:46pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/661 "2026-06-18T17:46:47Z")

</div>

> [@mhier](#):
>
> I found the problem. I had some samples in the fit data from before starting the ascend (but after the descend stopped), which was slightly off (maybe partially because of those oscillations from hitting the bed, partially because of base line wandering - there is a short pause before starting the ascend). Also maybe having so many data points in one place (far away from the interesting region) isn’t ideal for the fit.

Some suggestions - make sure you are only using data from the ascent move and make sure not to use samples near the “trigger\_time”. As mentioned earlier in this thread, the Klipper Z position tracking is not stable immediately after the trigger time, and that could poison your results. For example, the eddy code uses:

```python
        # Perform lifting move
        haltpos = toolhead.get_position()
        haltpos[2] += lift_dist
        retract_start_time = toolhead.get_last_move_time()
        toolhead.manual_move(haltpos, lift_speed)
        # Extract retract samples
        start_time = retract_start_time - 0.010
        end_time = retract_start_time + 0.150

```

Also, as you indicated, I’d suggest analyzing a finite window around the contact point - ideally all within about a mm of the contact point, as sampling noise far from the contact point may not be linear and that could skew the results.

Another suggestion would be to graph the results going into and out of the fitting code. You can find a test branch at [GitHub - KevinOConnor/klipper-dev at work-tap-20260224 · GitHub](https://github.com/KevinOConnor/klipper-dev/tree/work-tap-20260224) that has a `scripts/tap_graph.py` tool. Uncomment the `logging.info()` calls at the top and bottom of `find_best_fit()` and then run the tool on the klippy.log file to get a graph. (You’ll likely also need to update the `parse_log()` function in the tap\_graph.py code to parse out the modified log format.) I found visualizing the “tap” in a graph helped a lot during development.

> [@mhier](#):
>
> Since we can easily estimate the split point with an accuracy of 2-3 samples, we just have to do 2-3 iterations. This will be a lot faster and might even be numerically more stable.

For what it is worth, I think it’ll be simpler to add in “caching” to the least squares. The code I posted earlier is O(n\*n) complexity - which is slow if you have a large number of samples. However, all of the overhead is in the repeated calls to `mathutil.mat_mat_mul()`. Add in the caching and the algorithm becomes O(n) complexity, and then solving the least squares a few more times has no noticeable overhead. So, in other words, my suggestion would be to keep the elbow finding algorithm simple and then optimize the math calculations once everything else is ready.

Cheers,  
-Kevin

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [June 18, 2026, 7:54pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/662 "2026-06-18T19:54:20Z")

</div>

> [@koconnor](#):
>
> As mentioned earlier in this thread, the Klipper Z position tracking is not stable immediately after the trigger time, and that could poison your results. For example, the eddy code uses:

Yes, this is exactly what caused this issue, and I have solved it now more or less in the same way as the eddy probe (I also reject the first sample, which seems to still see acceleration or so).

> [@koconnor](#):
>
> Also, as you indicated, I’d suggest analyzing a finite window around the contact point - ideally all within about a mm of the contact point, as sampling noise far from the contact point may not be linear and that could skew the results.

True, I should limit the amount of samples used for the “tare”, e.g. same number as for the linear fit. This will come, but isn’t super important. At the moment, this is determined by the length of the ascend, which is ok for testing.

> [@koconnor](#):
>
> Another suggestion would be to graph the results going into and out of the fitting code.

Sure, I have done this already, see below. I have even replicated the fit routine so I can play with it offline.

> [@koconnor](#):
>
> For what it is worth, I think it’ll be simpler to add in “caching” to the least squares.

The solution I have in mind is half as long, especially if we split out a linear regression function and take it for granted (might be a good candidate for mathutils). It is also a lot easier to understand. Here is a first implementation:

```python
    def fit(self, z_vals, grams_vals):
        n = len(z_vals)
        best_err = float('inf')
        best_idx = None
        best_slope = best_intercept = best_avg = None

        for elbow in range(1, n - 1):
            # Left section: linear regression
            z_left = z_vals[:elbow + 1]
            g_left = grams_vals[:elbow + 1]
            m, b, _r = _linear_regression(z_left, g_left)

            # Right section: average
            g_right = grams_vals[elbow + 1:]
            avg = sum(g_right) / len(g_right)

            # Total squared error
            err = 0.0
            for z, g in zip(z_left, g_left):
                pred = m * z + b
                err += (g - pred) ** 2
            for g in g_right:
                err += (g - avg) ** 2

            if err < best_err:
                best_err = err
                best_idx = elbow
                best_slope = m
                best_intercept = b
                best_avg = avg

        # Compute intersection of linear fit with average
        # slope * z + intercept = avg => z = (avg - intercept) / slope
        z_contact = (best_avg - best_intercept) / best_slope
        grams_contact = best_avg # y at contact from the average side

        return z_contact, grams_contact, best_slope, best_intercept, best_avg, best_idx

```

(Plus of course the linear regression function, same as in my first implementation)

The result of this is exactly on top with the result from the code you posted:

 ![image](https://global.discourse-cdn.com/free1/uploads/klipper/original/3X/2/0/20040fbeca7be4df6013ed0b8513a4257c1bc45a.png)

I find this a lot easier to read. It’s your call of course… 🙂

(EDIT: Fixed swapped axis labels in plot. Sorry ;-))

---

<div class="post-metadata">

**Author:** ![koconnor](https://avatars.discourse-cdn.com/v4/letter/k/aca169/32.png) [@koconnor](https://klipper.discourse.group/u/koconnor)\
**Post date:** [June 19, 2026, 12:08am UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/663 "2026-06-19T00:08:41Z")

</div>

> [@mhier](#):
>
> The solution I have in mind is half as long

Well, that implementation has a subtle flaw - if you segment the data into two parts and analyze them separately, nothing stops it from finding a contact point that isn’t actually between the two segments.

So, for example, consider data points like the following (in `(z, adc)` format): `(0.01, 8000000), (0.02, 7000000), (0.03, 6000000), (0.04, 5000000), (0.05, 0), (0.06, 0), (0.07, 0), (0.08, 0), (0.09, 0), (0.10, 0)`. Nothing stops the simplified algorithm from finding a zero error linear least squares after segmenting into `samples[:4]` and `samples[4:]`. It would then conclude that the contact point is `z=0.09`, even though the contact point clearly isn’t there.

Maybe that isn’t an issue in practice, but I fear that simplified analysis may make the results more sensitive to sample noise around the point of contact. In particular, for faster sensors with more samples near the contact point and potentially more noise in each measurement. The code I posted earlier shouldn’t have this issue.

FWIW, I considered something similar for Eddy sensors and I ultimately decided to go with more math to avoid these kinds of subtle issues.

> [@mhier](#):
>
> I find this a lot easier to read.

Erh, was the x label and y label swapped on that graph? Otherwise I don’t understand the graph. (I’m guessing the labels were just swapped.)

> [@mhier](#):
>
> It’s your call of course…

I’m not making any “calls” here. I’m just looking to share some of my recent experiences with the Eddy tap implementation.

Cheers,  
-Kevin

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [June 19, 2026, 3:48pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/664 "2026-06-19T15:48:42Z")

</div>

Yes you are right, even though I think in reality this won’t make any significant difference.

> [@koconnor](#):
>
> Erh, was the x label and y label swapped on that graph? Otherwise I don’t understand the graph. (I’m guessing the labels were just swapped.)

Yes, sorry. I don’t know how this happened. I fixed the plot above to avoid further confusion.

> [@koconnor](#):
>
> I’m not making any “calls” here. I’m just looking to share some of my recent experiences with the Eddy tap implementation.

Sure, and I totally appreciate your input and experience. I was a bit worried that the code will get unnecessarily complicated which might be a maintenance burden. Since you are the maintainer, I am totally fine with using the more complicated code. That’s what I meant 🙂

So let’s stick to the eddy tap like implementation.

I will have to dig through code a bit further though to find the cause of the systematic error I am still seeing. There might be some timing problem like a shift by one ADC sample or so.

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [June 21, 2026, 8:12pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/665 "2026-06-21T20:12:23Z")

</div>

So, I have updated my implementation: [klipper/klippy/extras/load\_cell\_probe.py at lcp-linear-fit · RF1000community/klipper · GitHub](https://github.com/RF1000community/klipper/blob/lcp-linear-fit/klippy/extras/load_cell_probe.py)

The new thing is that I am now analysing the data from the ascent twice: The first time with all samples, the second time with only 10 samples around the contact position found in the first analysis (5 below and 5 above the position). The idea is to reduce effects caused by base line wandering, which might spoil the result. Typically, we should aim at having around 5 samples in the linear section (below the contact point), so we shall also use 5 samples for the tare averaging. This works now well for me. I don’t think, performance is much of a problem here, at least on my hardware (MKS SKIPR) it does not take a notable amount of time to compute. The algorithm aims at setups with slow samling rate, so we will always have quite few data points only. If you like, I can have a look into the least squares caching you mentioned.

I still have one other concern though: The results are consistently around 0.05mm too high in my case. I have isolated the problem to be some kind of coordinate system mismatch. The z positions I get from \_lookup\_z\_pos() seem to be off by 0.05mm w.r.t. the GCODE coodinate system. I have added code to dump the recorded sample data JSON format to the log file, including the decent data. If I start the decent (i.e. run the “PROBE” command) after moving the toolhead manually to Z=0 (via “G0 Z0”), the first z position in the JSON data from the log file is roughly at +0.05mm. What am I doing wrong here? I have essentially copied that code (\_lookup\_z\_pos()) from the eddy probe (\_lookup\_toolhead\_pos() there). Am I using it wrong?

---

<div class="post-metadata">

**Author:** ![farmercyst](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/farmercyst/32/22439_2.png) [@farmercyst](https://klipper.discourse.group/u/farmercyst)\
**Post date:** [June 23, 2026, 3:48pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/666 "2026-06-23T15:48:17Z")

</div>

This is an interesting thread and is also related to what I am working through in my own little side quest project with the BDpressure sensor. [GitHub - farmercyst/klipper: Klipper is a 3d-printer firmware · GitHub](https://github.com/farmercyst/klipper) I’m happy to help however possible. It doesn’t bother me to smash a nozzle into a bed on my test printer in the name of science! 😃

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [July 4, 2026, 10:10am UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/667 "2026-07-04T10:10:25Z")

</div>

I have found the source of my systematic error. It is basically unrelated to the probing algorithm. My procedure (in form of gcode macros) for bed leveling assumed that the z position after a probe run would be at the measured Z=0 position, which was the case for my old probe algorithm but is clearly not generally true. After fixing that, and especially when clearing the bed besh before the probe run, the systematic error is gone.

So I will now proceed with a bit code cleaning and prepare a pull request. Is there anything specific I should ideally improve before that?

@farmercyst There seems to be already an ADS1220 which should be compatible with the load cell probe implementation in Klipper. Why are you not using that? Is there any specialty of your hardware which require a different approach? Your approach seems to be pretty similar to @garethky original implementation to be honest, maybe your filtering is a bit different?

---

<div class="post-metadata">

**Author:** ![mhier](https://yyz2.discourse-cdn.com/free1/user_avatar/klipper.discourse.group/mhier/32/22365_2.png) [@mhier](https://klipper.discourse.group/u/mhier)\
**Post date:** [July 7, 2026, 11:49am UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/668 "2026-07-07T11:49:43Z")

</div>

btw: I created the PR: [improve precision of load\_cell\_probe for slow ADCs by mhier · Pull Request #7301 · Klipper3d/klipper · GitHub](https://github.com/Klipper3d/klipper/pull/7301)

---

<div class="post-metadata">

**Author:** ![mve1996](https://avatars.discourse-cdn.com/v4/letter/m/258eb7/32.png) [@mve1996](https://klipper.discourse.group/u/mve1996)\
**Post date:** [July 31, 2026, 10:27am UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/669 "2026-07-31T10:27:05Z")

</div>

Hello. I need to fit a load cell between the direct-drive extruder and hotend on my Ender 3 S1 Pro so I can monitor and control back-pressure in real time. Space is tight and I do not yet have precise measurements. I am looking for someone who can design the correct adapter for me and I will pay for it.

---

<div class="post-metadata">

**Author:** ![cardoc](https://avatars.discourse-cdn.com/v4/letter/c/ec9cab/32.png) [@cardoc](https://klipper.discourse.group/u/cardoc)\
**Post date:** [July 31, 2026, 6:01pm UTC](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134/670 "2026-07-31T18:01:19Z")

</div>

I got you. $240 delivered. I’ll throw in a toolhead CAN board miscellaneous parts.

> **[Sovol SV06 ACE 3D Printer](https://www.sovol3d.com/products/sovol-sv06-ace)**
>
> Sovol SV06 ACE is a 3D printer with a speed of up to 600mm/s. It can print a 3D Benchy in 13 minutes; it is very advantageous among similar products. Pre-assembled components can be assembled in 15 minutes. Fully automatic leveling technology can...

The POS has a “hybrid” mainboard with a H616 embedded. Put your ender board in this printer or just buy a toolhead ($63) and adapt to mount on your ender.

[Previous page](https://klipper.discourse.group/t/strain-gauge-load-cell-based-endstops/2134.md?page=32)
