Coverage for src / zooc / run / measure_z.py: 25%
104 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 21:45 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 21:45 +0000
1"""Methods for measuring Z-offset at certain, fixed temperature."""
2from __future__ import annotations
4import logging
5import statistics
6from abc import ABC, abstractmethod
7from dataclasses import dataclass, field
8from typing import Final, override
10from klipper_utils.klipper_utils import KlipperUtilsError
11from zooc.dsp.dither import dither_weights
13from .model_z_offset import ModelZOffset, ModelZOffsetExp, ModelZOffsetStable
14from .run_calibrate import RunCalibrate
16logger = logging.getLogger(__name__)
19@dataclass(kw_only=True)
20class MeasureZ(RunCalibrate, ABC):
21 """Base class for various methods measuring Z-offset.
23 :param klipper_module: See parent class: :py:class:`.run_calibrate.RunCalibrate`.
24 :param gcmd: See parent class: :py:class:`.run_calibrate.RunCalibrate`.
25 :param run_config: See parent class: :py:class:`.run_calibrate.RunCalibrate`.
26 """
28 @abstractmethod
29 def measure(self) -> tuple[float, dict[str, object]]:
30 """Run the measurement process at current temperatures.
32 :return: Measured Z-offset and auxiliary data.
33 :raises KlipperUtilsError: If the Z-offset didn't stabilize after the maximum number of tries.
34 """
36 def _stabilize_delay(self, delay_s: float | None = None) -> None:
37 """Wait for the system to stabilize before measuring.
39 :param delay_s: Delay to wait or None to use default [s].
40 """
41 if delay_s is None:
42 delay_s = self.run_config.wait_stabilize_s
43 if delay_s <= 0:
44 logger.warning("Stabilize delay is too short. Increase delay")
45 self.klipper_module.wait(self.gcmd, duration=delay_s)
47 def _measure_z(self, count: int = 4) -> tuple[float, float, float]:
48 """Measure z-offset with few measurements.
50 This uses the probe to measure the Z-offset, applying an unbiased dither pattern to the probe noise parameter.
52 :param count: Number of measurements to take.
53 :return: Sample time, Mean and standard deviation.
54 """
55 time_start = self.klipper_module.get_time()
57 # Generate dither values. Match with count to get distinct weight for each measurement
58 weights = dither_weights(count=count, dither_levels=count)
60 # Perform measurements
61 values = []
62 for dither_weight in weights:
63 values.append(self._get_single_probe(samples=1, dither_weight=dither_weight))
65 time_end = self.klipper_module.get_time()
66 time_sample = (time_end + time_start) / 2.0
68 if count == 1:
69 return time_sample, values[0], 0.0
71 return time_sample, statistics.mean(values), statistics.stdev(values)
73 def _get_single_probe(self, samples: int = 1, dither_weight: float = 0.0) -> float:
74 """Run the probe and return the Z-offset.
76 :param samples: Number of samples to take. Note, currently only the last sample is accounted.
77 :param dither_weight: Dithering weight -1.0...+1.0.
78 :return: The last probed Z-offset.
79 :raises KlipperUtilsError: If the probe is not found or fails.
80 """
81 probe = self.klipper_module.printer.lookup_object('probe', None)
82 if probe is None:
83 raise KlipperUtilsError("Probe is required for Z-offset measuring")
85 probe_speed_mm_s = 5.0 # default is 5.0 mm/s
87 # Dithering: Try to introduce small variation and increase resolution to change speed a bit higher or lower
88 # max jitter amplitude / maximum speed change (+-) caused by the dithering.
89 # this should equal to roughly to 1 LSB (or 0.025 mm, typical probe's resolution)
90 speed_1_lsb_mm_s = 0.5
91 probe_speed_mm_s += dither_weight * speed_1_lsb_mm_s
93 sample_retract_dist = 5.0 # default is 5.0 mm
94 lift_speed = 5.0 # default is 5.0 mm/s
95 # Move above until probe is not triggered
96 self.klipper_module.move_inside(move_inside=(None, None, (+sample_retract_dist, +sample_retract_dist + 10.0)), speed=lift_speed)
98 # PROBE arguments
99 # PROBE [PROBE_SPEED=<mm/s>] [LIFT_SPEED=<mm/s>] [SAMPLES=<count>] [SAMPLE_RETRACT_DIST=<mm>] [SAMPLES_TOLERANCE=<mm>] [SAMPLES_TOLERANCE_RETRIES=<count>] [SAMPLES_RESULT=median|average]
100 # https://www.klipper3d.org/G-Codes.html#probe
101 # https://www.klipper3d.org/Config_Reference.html?h=probe#probe
102 if samples == 0:
103 self.klipper_module.gcode.run_script_from_command(f"PROBE SPEED={probe_speed_mm_s}")
104 else:
105 self.klipper_module.gcode.run_script_from_command(f"PROBE SAMPLES={samples} PROBE_SPEED={probe_speed_mm_s}")
107 # Get the probe result
108 # The average value is not available. Use the value of last probing. This is a bit vague, but repeatable.
109 z_offset = float(probe.get_status(None)['last_z_result'])
111 # Lift nozzle above at the end
112 self.klipper_module.move_relative(move_by=(None, None, +sample_retract_dist), speed=lift_speed)
113 return z_offset
116@dataclass(kw_only=True)
117class MeasureZDelay(MeasureZ):
118 """Simple measuring with a fixed delay stabilization.
120 :param klipper_module: See parent class: :py:class:`MeasureZ`.
121 :param gcmd: See parent class: :py:class:`MeasureZ`.
122 :param run_config: See parent class: :py:class:`MeasureZ`.
123 """
125 @override
126 def measure(self) -> tuple[float, dict[str, object]]:
127 self._stabilize_delay()
128 return self._get_single_probe(samples=3), {}
131@dataclass(kw_only=True)
132class MeasureZDelta(MeasureZ):
133 """Simple Z-offset measure by measuring until the offset is stable.
135 :param klipper_module: See parent class: :py:class:`MeasureZ`.
136 :param gcmd: See parent class: :py:class:`MeasureZ`.
137 :param run_config: See parent class: :py:class:`MeasureZ`.
138 """
140 @override
141 def measure(self) -> tuple[float, dict[str, object]]:
142 require_success = 3 # [1...*] How many times the measurement is determined stable in a row. 2+ avoids the frame expansion and nozzle cooling to cancel each other out.
143 success = 0
144 sd_threshold = 1.5 # [1...3] Required threshold between measurements. Standard deviation coefficient
146 max_tries = 20
147 z_prev = None
148 for _ in range(max_tries):
149 self._stabilize_delay()
150 _time_start, z_mean, z_sd = self._measure_z()
151 if z_prev is not None:
152 logger.info(f"Measure-z: {success+1}/{require_success} Z-offset={z_mean} SD={z_sd} delta={z_mean - z_prev} ")
153 if abs(z_mean - z_prev) <= z_sd * sd_threshold:
154 success += 1
155 if success >= require_success:
156 return z_mean, {}
157 else:
158 success = 0
159 z_prev = z_mean
160 raise KlipperUtilsError(f"Z offset didn't stabilize after {max_tries}")
163@dataclass(kw_only=True)
164class MeasureZForecast(MeasureZ):
165 """Measure multiple samples over time and estimate the final Z-offset.
167 If the temperature is stable, use a stable model. Otherwise, use an exponential decay model.
169 :param klipper_module: See parent class: :py:class:`MeasureZ`.
170 :param gcmd: See parent class: :py:class:`MeasureZ`.
171 :param run_config: See parent class: :py:class:`MeasureZ`.
172 :param stable_temp_delta: Stable model: Maximum temperature delta to consider the temperature stable [°C].
173 :param t_end_s: Maximum time to measure [s].
174 :param forecast_duration_s: Stable model: Time in the future to forecast the Z-offset starting from the last sample [s].
175 :param max_delta: Exp model: Maximum allowed deviation between successful measurements.
176 """
178 stable_temp_delta: Final[float] = field(default=2.0, repr=False) # pylint: disable=invalid-name
179 max_delta: Final[float] = field(default=0.005, repr=False) # pylint: disable=invalid-name
181 t_end_s: Final[float] = field(default=300, repr=False) # pylint: disable=invalid-name
182 forecast_duration_s: Final[float] = field(default=120, repr=False) # pylint: disable=invalid-name
184 @override
185 def measure(self) -> tuple[float, dict[str, object]]:
186 model_exp = ModelZOffsetExp()
187 # If offset is not exponentially decaying. Perhaps it has settled to final value. Try median filter.
188 model_stable = ModelZOffsetStable(t_end=self.t_end_s, t_forecast=self.forecast_duration_s)
190 while not model_stable.completed:
191 # Warm up the probe running it couple times
192 time_warmup, _, _ = self._measure_z(count=2)
194 # Oversample: take many samples frequently and get mean to improve the resolution
195 time_sample, z_mean, _ = self._measure_z(count=4)
197 # Use stable modeling only at stable temperatures
198 temp_curr, temp_target = self.klipper_module.get_temperatures()
199 temp_delta = temp_curr - temp_target
200 if ((temp_delta.bed.is_off() or abs(temp_delta.bed_temp) < self.stable_temp_delta) and
201 (temp_delta.extruder.is_off() or abs(temp_delta.extruder_temp) < self.stable_temp_delta)):
202 model_stable.calc_offset(sample=(time_sample, z_mean), max_delta=self.max_delta)
204 # Stop when model_exp is ready
205 if model_exp.calc_offset(sample=(time_sample, z_mean), max_delta=self.max_delta):
206 break
208 self._stabilize_delay(self.run_config.wait_stabilize_s - 2.0 * (time_sample - time_warmup))
210 # Prioritize exponential model, fallback to stable model
211 model_final: ModelZOffset
212 if model_exp.z_prev is not None:
213 model_final = model_exp
214 elif model_stable.z_prev is not None:
215 model_final = model_stable
216 else:
217 raise KlipperUtilsError("Z-offset measurement didn't success.")
219 if model_final.z_prev is None: # linting check
220 raise KlipperUtilsError("error")
222 logger.info(f"Measured: z={model_final.z_prev}, model={model_final.describe()}, {ModelZOffset.log_data(model_final.filter_model)}")
223 return model_final.z_prev, model_final.filter_model.describe() if model_final.filter_model else {}