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

1"""Methods for measuring Z-offset at certain, fixed temperature.""" 

2from __future__ import annotations 

3 

4import logging 

5import statistics 

6from abc import ABC, abstractmethod 

7from dataclasses import dataclass, field 

8from typing import Final, override 

9 

10from klipper_utils.klipper_utils import KlipperUtilsError 

11from zooc.dsp.dither import dither_weights 

12 

13from .model_z_offset import ModelZOffset, ModelZOffsetExp, ModelZOffsetStable 

14from .run_calibrate import RunCalibrate 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19@dataclass(kw_only=True) 

20class MeasureZ(RunCalibrate, ABC): 

21 """Base class for various methods measuring Z-offset. 

22 

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 """ 

27 

28 @abstractmethod 

29 def measure(self) -> tuple[float, dict[str, object]]: 

30 """Run the measurement process at current temperatures. 

31 

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 """ 

35 

36 def _stabilize_delay(self, delay_s: float | None = None) -> None: 

37 """Wait for the system to stabilize before measuring. 

38 

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) 

46 

47 def _measure_z(self, count: int = 4) -> tuple[float, float, float]: 

48 """Measure z-offset with few measurements. 

49 

50 This uses the probe to measure the Z-offset, applying an unbiased dither pattern to the probe noise parameter. 

51 

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() 

56 

57 # Generate dither values. Match with count to get distinct weight for each measurement 

58 weights = dither_weights(count=count, dither_levels=count) 

59 

60 # Perform measurements 

61 values = [] 

62 for dither_weight in weights: 

63 values.append(self._get_single_probe(samples=1, dither_weight=dither_weight)) 

64 

65 time_end = self.klipper_module.get_time() 

66 time_sample = (time_end + time_start) / 2.0 

67 

68 if count == 1: 

69 return time_sample, values[0], 0.0 

70 

71 return time_sample, statistics.mean(values), statistics.stdev(values) 

72 

73 def _get_single_probe(self, samples: int = 1, dither_weight: float = 0.0) -> float: 

74 """Run the probe and return the Z-offset. 

75 

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") 

84 

85 probe_speed_mm_s = 5.0 # default is 5.0 mm/s 

86 

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 

92 

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) 

97 

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}") 

106 

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']) 

110 

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 

114 

115 

116@dataclass(kw_only=True) 

117class MeasureZDelay(MeasureZ): 

118 """Simple measuring with a fixed delay stabilization. 

119 

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 """ 

124 

125 @override 

126 def measure(self) -> tuple[float, dict[str, object]]: 

127 self._stabilize_delay() 

128 return self._get_single_probe(samples=3), {} 

129 

130 

131@dataclass(kw_only=True) 

132class MeasureZDelta(MeasureZ): 

133 """Simple Z-offset measure by measuring until the offset is stable. 

134 

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 """ 

139 

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 

145 

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}") 

161 

162 

163@dataclass(kw_only=True) 

164class MeasureZForecast(MeasureZ): 

165 """Measure multiple samples over time and estimate the final Z-offset. 

166 

167 If the temperature is stable, use a stable model. Otherwise, use an exponential decay model. 

168 

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 """ 

177 

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 

180 

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 

183 

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) 

189 

190 while not model_stable.completed: 

191 # Warm up the probe running it couple times 

192 time_warmup, _, _ = self._measure_z(count=2) 

193 

194 # Oversample: take many samples frequently and get mean to improve the resolution 

195 time_sample, z_mean, _ = self._measure_z(count=4) 

196 

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) 

203 

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 

207 

208 self._stabilize_delay(self.run_config.wait_stabilize_s - 2.0 * (time_sample - time_warmup)) 

209 

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.") 

218 

219 if model_final.z_prev is None: # linting check 

220 raise KlipperUtilsError("error") 

221 

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 {}