Skip to content

Conversation

@garethky
Copy link
Contributor

@garethky garethky commented Oct 19, 2025

The goal of this PR is to get from a gram scale all the way to being able to home a printer with a load cell.

An overview of whats in here:

  • The SOS Filter is created as a new utility component on the MCU.
  • The load_cell_probe is introduced on the MCU - this allows load cell sensors to act as data sources for an endstop that lives on the MCU. It can detect when a sudden force on the load cell indicates a collision. The SOS filter is used to do this.
  • The Load Cell sensors are modified so they can send data to a load_cell_probe on the MCU.
  • Finally the LoadCellProbe is introduced on the host that can combine a load cell sensor the load_cell_probe together to turn it into a probe.

SOS Filter

This is a general purpose fixed point SOS filter implementation. It supports a maximum of 4 filter sections.

The primary purpose it to reject drift and noise while homing. There is a post about this here.
This idea was originally introduced by Prusa Research code here. Their implementation uses an old code generator, mkfilter, and can't be easily changed without re-compilation.

This implementation uses the more modern Second Order Sections format and uploads each section to the MCU to initialize the filter. This has a number of advantages for kalico:

  • This implementation uses fixed point math to get around limitations in kalico's communications protocol and potential issues using the FPU's on chip.
  • The SOS algorithm avoids using a costly division instruction in the filter loop, saving hundreds of clock cycles.
  • The only requirement for fast execution is single cycle SMULL which is a feature of every ARM Cortex M0+ core.
  • The filter is entirely reusable by other analog probe types.

LoadCellProbe

Most of the code in this PR is about load_cell_probe MCU implementation. This is not the end of the code for the load_cell_probe.py. There are another ~1000 lines of code that will go into this file to do the post homing Tap Analysis. I see value in keeping these chunks of functionality separate and combining them with composition. People have reached out to me to do a Filament Scale that would have specific functionality for weighing filament, calculating filament used in a print and so on. Keeping load_cell relatively small and simple will let that development happen independently.

Kalico doesn't have the PrinterProbe interface as klipper. The commit that adds LoadCellProbe has been re-written to work with ProbeEndstopWrapper. Homing with loadcell probe works, but it will never do the high accuracy pullback move to refine the z=0 position because that would move the toohead while the machine is not homed. Probing after homing is required to get a high accuracy result. In #756, I added a PROBE HOME=z to simplify this process.

@garethky garethky force-pushed the pr-load-cell-probe branch 16 times, most recently from 6bda0b9 to 7edd998 Compare October 21, 2025 20:50
Copy link
Contributor

@dalegaard dalegaard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the MCU need to work in grams? Can't it simply work in and report counts to the host instead? Then the host, with its oodles of cycles, can perform the conversion?

def load_config(config):
# Sensor types
sensors = {}
sensors.update(hx71x.HX71X_SENSOR_TYPES)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like having a static list here, this should be dynamically loaded.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can we do that?

Copy link
Contributor

@dalegaard dalegaard Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One option could be to expose the sensors thing in a register_sensor_factory function, like it's done today for heaters with add_sensor_factory. A pattern that could work:

  • User specifies sensor: hx71x.
  • We lookup_object("hx71x")
  • hx71x modules' load_config does a lookup_object("load_cell") and calls register_sensor_factory to register itself
  • We call the registered hx71x sensor factory.

This way:

  • we don't need to add any lookup/load object changes
  • the sensor list isn't static
  • all the dynamic loading stuff exists only within load_cell/__init__.py

Copy link
Contributor Author

@garethky garethky Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at the existing pattern for heater sensors:

def load_config(config):
    # Register sensor
    pheaters = config.get_printer().load_object(config, "heaters")
    pheaters.add_sensor_factory("BME280", BME280)

This code is run because of a registry file, temperature_sensors.cfg. Heaters loads this file. So that's not a dynamic discovery system, that's a registry, so it wont work with plugins.

I'm on board for a fully dynamic system but it needs to solve a few things:

The senor file name is not the same as the sensor_type. Sensor modules can export multiple sensor types. (hx71x.py exports hx717 and hx711). But if all you have is a sensor_type you have no way to perform the reverse lookup to get to the module name to load. The registry 'hack' solves this issue for temperature sensors.

I don't think the individual sensors should be looking up an object. This runs into naming issues. e.g. load_cell cant be the name because it doesn't have to be registered. The user is free to name the load cells because they can have multiples. Heaters gets around this by registering heaters, by analogy I'd need to register load_cells to serve as the collection & registry. I'd rather not pollute the printer object registry with names that the user did not configure.


Proposal: Find all modules with a name that ends in a specific string: hx71x__load_cell_sensor.py. In that way you are exploiting the file system as a registry. Just give back the modules and don't run any load_config. Then the caller can decide how to treat them:

The in load_cell/__init__.py you can do something like this to build the registry once:

sensor_registry = {}
sensor_modules = printer.find_modules("__load_cell_sensor")
for sensor_module in sensor_modules:
    sensor_registry.update((sensor_module.register_load_cell_sensors())

This way:

  • The sensor modules never get registered as objects, minimize clutter in the printer's object space which the user can see.
  • Sensor modules can contribute multiple sensor_type entries
  • Any plugin that copies the naming convention can be a sensor
  • No central registry is needed, other than the file system

Copy link
Contributor Author

@garethky garethky Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now implemented in #769 and this PR now depends on that one.

self._overflows = 0

# move from the started to stopped state and trigger the completion
def _notify(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would prefer name like complete or something, since this also has the side effect of setting is_started = False

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed to _complete()

@garethky
Copy link
Contributor Author

Why does the MCU need to work in grams? Can't it simply work in and report counts to the host instead? Then the host, with its oodles of cycles, can perform the conversion?

Counts cant be used directly because they could overflow a FIXEDQ16. The sensors are 24 bit, 32 bit sensors exist. Its not defined how many count bits make up a gram. So the conversion to grams means that if the sensor has an excess of bits (has high resolution), the least significant bits are discarded. e.g. if 1 gram requires 18 bits to represent in sensor counts, it gets converted to grams on the MCU so it fits in FIXEDQ16 and requires only 1 bit of integer space. The math works the same for all sensor bit widths and physical/electrical setups.

Also, from painful experience, you want to use grams for debugging. When its a random value in counts you cant easily tell whats going on. With grams its easy enough to print out a value (just shift it right 15 bits) and be sure things are filtering correctly.

counts_to_grams() is optimized not to require a division instruction. It has 3 instructions that all run in a single clock cycle on an M0+. So the overhead is minimal.

This change adds a new component registration mechanism that allows modules to register named components with one or more subsystems. The registration happens before printer object creation so no access to Printer is required, limiting possible side effects of registration. This creates a way to do truly dynamic registration without having registries in code, special directories, or files with lists of components. Modules can get registered components at `load_config`\`load_config_prefix` time with a call to `Printer.lookup_components`.

Most of the PR is re-working `Printer.load_object` so that it can use a cache of loaded modules. The cache is built prior to any printer objects being created. Having the cache allows for all modules to be scanned for a `register_components` function which is called before the config file is processed.


Signed-off-by: Gareth Farrington <[email protected]>
@garethky garethky force-pushed the pr-load-cell-probe branch 8 times, most recently from 345fe9b to 53b48ca Compare November 5, 2025 21:37
printer_homing: homing.PrinterHoming = self._printer.lookup_object(
"homing"
)
return printer_homing.probing_move(mcu_probe, pos, speed), collector
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this have a cleanup of collector in case of probing move failure, along the lines of Klipper3d/klipper#7004 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I can bring that commit forward in the stack so its in this PR.

@garethky garethky force-pushed the pr-load-cell-probe branch 2 times, most recently from 7cea68e to fc3af6e Compare November 8, 2025 01:11
Implement a single `_retract` method so it is always performed consistently. This prepares for probe retry strategies that move the probe in x/y.


Signed-off-by: Gareth Farrington <[email protected]>
Pass the GCodeCommand object to the `probing_move` method. This allows probes access to any probe specific custom parameter that might be in the command when probing.


Signed-off-by: Gareth Farrington <[email protected]>
This has diplicated code and is unecessarily complex leading to further complexity in later changes.


Signed-off-by: Gareth Farrington <[email protected]>
@garethky garethky force-pushed the pr-load-cell-probe branch 4 times, most recently from b2a2fe4 to c2fb998 Compare November 10, 2025 21:21
Nozzle probes suffer from ooze. If a probe can detect that the nozzle was fouled it can report that infromation back to the probing system. This change adds an optional return parameter, `is_good`, to the `probing_move` interface. If a probe is not good, it can take action to execute additional probes to resolve the issue.

This is all under control of the user via the retry strategy. The strategies are: FAIL, IGNORE, RETRY and CIRCLE. RETRY and CIRCLE re-attempt probes with CIRCLE moving the probe to a clean location in a very small circle around the original probe location.

CIRCLE is the preferred strategy for bed meshing. FAIL or RETRY might be a good strategy for something like QGL where the absolute position of the probe is critical. IGNORE is a good strategy when probing things that are not as rigid as the bed, such as a nozzle scrubber.


Signed-off-by: Gareth Farrington <[email protected]>
If using the CIRCLE stratey and retrying multiple times at the same points (e.g. for QGL) any fouled points are rememberd and avoided by keeping a session between attempts (calls to `start_probe`). The number of retries are reset for all points on each pass but the fouled points are saved.


Signed-off-by: Gareth Farrington <[email protected]>
Allow the user to configure their own custom nozzle scrubbing routine that can integrate with custom nozzle scrubbing hardware, such as a brush or wiper. This is invoked when a probe fails. The SCRUBBING_FREQUENCY parameter allows this to be combined with retry strategies, particularly the CIRCLE and RETRY strategies. This can provide enhanced intermittent scrubbing if there is a problem with tapping to clear a fouled nozzle.


Signed-off-by: Gareth Farrington <[email protected]>
This is a bit of a tricky/archane macro to write so this saves you the trouble. PROBE HOME=z perfectly homes your Z axis if you have a nozzle probe.

HOME=z was used so later we can probe in x/y and submit alternate axis names.


Signed-off-by: Gareth Farrington <[email protected]>
empty commit for merging branches
Signed-off-by: Gareth Farrington <[email protected]>
This is an implementation of the SOS fliltering algorithm that runs on the MCU.

The filter opperates on data in fixed point format to avoid use of the FPU as klipper does not support FPU usage.

This host object handles duties of initalizing and resetting the filter so client dont have to declare their own commands for these opperations. Clients can select how many integer bits they want to use for both the filter coefficients and the filters output value. An arbitrary number of filter sections can be configured. Filters can be designed on the fly with the SciPy library or loaded from another source.

Signed-off-by: Gareth Farrington <[email protected]>
Implement MCU features that enable using an adc to stop an axis

Signed-off-by: Gareth Farrington <[email protected]>
garethky and others added 7 commits November 16, 2025 20:27
Initial setup of Load Cell Probing. This implementation supports triggering from the Load Cell Probe on the MCU. It also supports, optiopnal, filtering of the force signal by sos filter to eliminate drift caused by bowden tubes or other mechanical causes.


Signed-off-by: Gareth Farrington <[email protected]>
Add a filter workbench Jupiter notebook to help printer developers tune filters based on probing data

Signed-off-by: Gareth Farrington <[email protected]>
Add documentation updates for Homing & Probing with load cell probe

Signed-off-by: Gareth Farrington <[email protected]>

docs: remove hard line breaks from Load_Cell.md

ai mucking around
Re-write documentation for tersness
Validate host provided index prior to accessing memory using that
index.

Also, consistently use a uint8_t for max_sections (to account for
integer overflow issues).

Signed-off-by: Kevin O'Connor <[email protected]>
Catch exceptions raised by the homing module and terminate the collector before re-raising the exception.


Signed-off-by: Gareth Farrington <[email protected]>
Instead of polling on an interval, this uses ReactorCompletion to wait until the timeout or the data is delivered. This saves wasted time caused by polling delay.


Signed-off-by: Gareth Farrington <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants