Coverage for src / zooc / run / run.py: 57%

99 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-11 21:45 +0000

1"""Z-offset calibration process.""" 

2import logging 

3import math 

4import statistics 

5from dataclasses import dataclass, field 

6 

7from klipper_utils.klipper_module import KlipperModule 

8from klipper_utils.klipper_utils import ConfigWrapper, Gcmd 

9from zooc.data.calibration_data import CalibrationData 

10from zooc.data.offset_data import OffsetData 

11from zooc.data.offset_temp import OffsetTemp 

12from zooc.data.temps import Temps 

13 

14from .measure_methods import MeasureMethods 

15from .measure_z import MeasureZ 

16from .run_config import RunConfig 

17from .temp_scan_methods import TempsScanMethods 

18from .temps_scan import TempScan 

19 

20logger = logging.getLogger(__name__) 

21 

22 

23@dataclass(kw_only=True) 

24class Run: 

25 """Runs a calibration routine with different bed and extruder temperatures and measures the change in Z-offset with a probe. 

26 

27 :param gcmd: Klipper's G-code command object running the calibration. 

28 :param run_config: Configuration for the calibration run. 

29 :param klipper_module: Klipper module instance to interact with the printer. 

30 :raises gcmd.error: If the reference temperatures are not within the calibration points. 

31 """ 

32 

33 @staticmethod 

34 def klipper_config(config: ConfigWrapper) -> RunConfig: 

35 """Build the RunConfig from the klipper configuration file. 

36 

37 :param config: Klipper configuration object to load the data from. 

38 :return: RunConfig object with the settings from the configuration. 

39 """ 

40 return RunConfig(temps_ref=Temps.klipper(config.getfloat("ref_bed_temp", minval=+20.0, maxval=150.0), 

41 config.getfloat("ref_extruder_temp", default=0.0, minval=+20.0, maxval=400.0)), 

42 horizontal_move_speed=config.getfloat("horizontal_move_speed", default=10.0, minval=+1.0), 

43 bed_temps=config.getfloatlist("bed_temps"), 

44 extruder_temps=config.getfloatlist("extruder_temps", default=[0.0]), 

45 wait_stabilize_s=config.getfloat("wait_stabilize_s", default=30.0, minval=0.0, maxval=3600.0), 

46 measure_method=MeasureMethods.get_value(config=config), 

47 zero_reference_position=config.getfloatlist("zero_reference_position", None, count=2), 

48 pre_run_gcode=config.getlists("pre_run_gcode", seps='\n', default=[]), 

49 post_run_gcode=config.getlists("post_run_gcode", seps='\n', default=[]), 

50 temps_scan_method=TempsScanMethods.get_value(config=config), 

51 ) 

52 

53 gcmd: Gcmd 

54 run_config: RunConfig 

55 klipper_module: KlipperModule 

56 temp_step: int = field(init=False, default=0) 

57 

58 def run(self) -> CalibrationData: 

59 """Start the calibration process. 

60 

61 :return: Calibration results. 

62 :raises: self.gcmd.error: If the printer is not ready or the calibration fails. 

63 """ 

64 toolhead = self.klipper_module.printer.lookup_object("toolhead") 

65 if toolhead is None: 

66 raise self.gcmd.error("Printer not ready") 

67 

68 for gcode in self.run_config.pre_run_gcode: 

69 self.klipper_module.gcode.run_script_from_command(gcode) 

70 

71 time_start = self.klipper_module.get_time() 

72 try: 

73 offset_temps = self._calibrate(klipper_module=self.klipper_module) 

74 calibration_data = self.analyse(offset_temps) 

75 

76 # Show and store the results 

77 time_end = self.klipper_module.get_time() 

78 

79 logger.info(f"Duration={(time_end - time_start):.1f} s") 

80 

81 return calibration_data 

82 

83 finally: 

84 # Turn heating off 

85 self.klipper_module.set_temperatures(gcmd=self.gcmd, temps=Temps.klipper(0.0, 0.0)) 

86 

87 def _calibrate(self, klipper_module: KlipperModule) -> list[OffsetTemp]: 

88 # Start calibration 

89 self._prepare_printer() 

90 class_temps_scan: type[TempScan] = self.run_config.temps_scan_method.value 

91 temps_scan = class_temps_scan(klipper_module=klipper_module, gcmd=self.gcmd, run_config=self.run_config) 

92 temps_steps = temps_scan.get_temp_steps() 

93 self.temp_step = 0 

94 

95 # For consistency, first run to a bit lower temperatures after heating to final temperatures 

96 # Set both extruder and bed temperatures simultaneously and wait temps to settle. 

97 def temps_set(temps_to: Temps) -> None: 

98 temps_offset_cool = Temps.create(2, 10) 

99 temps_offset_wait = Temps.create(0, 0) 

100 self.temp_step += 1 

101 logger.info(f"Calibrating at {temps_to}. (phase {self.temp_step}/{temps_steps})") 

102 self._heat(temps_to=temps_to, temps_offset_cool=temps_offset_cool, temps_offset_wait=temps_offset_wait) 

103 

104 class_measure_z: type[MeasureZ] = self.run_config.measure_method.value 

105 measure_z = class_measure_z(klipper_module=klipper_module, gcmd=self.gcmd, run_config=self.run_config) 

106 return temps_scan.scan(measure_z, temps_set) 

107 

108 def _prepare_printer(self) -> None: 

109 """Prepare the printer for calibration, e.g., moving the toolhead to a proper position.""" 

110 self.klipper_module.check_homed_xyz(self.gcmd) 

111 self.klipper_module.move_absolute(self.run_config.zero_reference_position, self.run_config.horizontal_move_speed) 

112 

113 def _heat(self, temps_to: Temps, temps_offset_cool: Temps | None = None, temps_offset_wait: Temps | None = None) -> None: 

114 """Set bed and extruder to <temps_to> ensuring the temperature is reached by heating, not cooling. 

115 

116 First wait and cool printer <temps_to> - <temps_offset_cool> followed by heating. This stabilizes the temperature. 

117 Then heat and wait printer to <temps_to> - <temps_offset_cool>. 

118 Finally set printer to heat to <temps_to> and return immediately. 

119 

120 :param temps_to: Final temperature target to heat to. 

121 :param temps_offset_cool: Block until printer is cooled to this offset from final target. 

122 :param temps_offset_wait: Block until printer is heated to this offset from final target. 

123 """ 

124 temps_offset_cool = Temps.create(0, 0) if temps_offset_cool is None else temps_offset_cool 

125 temps_offset_wait = Temps.create(0, 0) if temps_offset_wait is None else temps_offset_wait 

126 

127 assert temps_offset_cool.bed_temp >= 0.0 and temps_offset_cool.extruder_temp >= 0.0 

128 assert temps_offset_wait.bed_temp >= 0.0 and temps_offset_wait.extruder_temp >= 0.0 

129 temps_curr, temps_target = self.klipper_module.get_temperatures() 

130 

131 # Only if the temperature is being changed. 

132 bed_enable = temps_target.bed_temp != temps_to.bed_temp 

133 extruder_enable = temps_target.extruder_temp != temps_to.extruder_temp 

134 

135 # cool printer if current temperature is higher than cool temperature. 

136 temps_cool = temps_to - temps_offset_cool 

137 temps_cool = temps_cool.enable(bed_enable=bed_enable and temps_cool.bed_temp < temps_curr.bed_temp, 

138 extruder_enable=extruder_enable and temps_cool.extruder_temp < temps_curr.extruder_temp) 

139 self.klipper_module.set_temperatures(gcmd=self.gcmd, temps=temps_cool) 

140 self.klipper_module.wait_temperatures(gcmd=self.gcmd, temps=temps_cool, tolerance_min=math.inf, tolerance_max=0.0) 

141 self.klipper_module.set_temperatures(gcmd=self.gcmd, temps=temps_to) 

142 

143 # wait for intermediate temperature if current temperature is lower. 

144 temps_wait = temps_to - temps_offset_wait 

145 temps_wait = temps_wait.enable(bed_enable=bed_enable and temps_wait.bed_temp > temps_curr.bed_temp, 

146 extruder_enable=extruder_enable and temps_wait.extruder_temp > temps_curr.extruder_temp) 

147 self.klipper_module.wait_temperatures(self.gcmd, temps_wait, tolerance_min=0.0, tolerance_max=math.inf) 

148 

149 # noinspection PyMethodMayBeStatic 

150 def _analyse_temp(self, temp_offsets: dict[float, list[float]]) -> float: 

151 """Analyze the average value of dictionary with multiple values. 

152 

153 :param temp_offsets: Dictionary with multiple values. 

154 :return: Average value for all keys and values. 

155 """ 

156 # Calculated statistics for time constant values for each temperature step. 

157 tau_temp_stats: dict[float, tuple[float, float]] = {} 

158 for temp, values in temp_offsets.items(): 

159 sd = 0.0 

160 if len(values) > 1: 

161 sd = statistics.stdev(values) 

162 tau_temp_stats[temp] = statistics.mean(values), sd 

163 

164 # Get average of all values in temp_offsets. 

165 time_constant = float('NaN') 

166 if len(tau_temp_stats) > 0: 166 ↛ 168line 166 didn't jump to line 168 because the condition on line 166 was always true

167 time_constant = statistics.mean([v[0] for v in tau_temp_stats.values()]) 

168 return time_constant 

169 

170 def analyse(self, offset_temps: list[OffsetTemp]) -> CalibrationData: 

171 """Analyze the Z-offset measurements. 

172 

173 :param offset_temps: Z-offset measurements. 

174 :return: Calibration results. 

175 """ 

176 tau_bed: dict[float, list[float]] = {} 

177 tau_extruder: dict[float, list[float]] = {} 

178 temps_prev: Temps | None = None 

179 

180 for offset_temp in offset_temps: 

181 # Collect time constants if calibration provides it 

182 tau = offset_temp.aux_data.get('tau') 

183 temps = offset_temp.temps 

184 if temps_prev and tau and isinstance(tau, float): 

185 # Collect time constant when one of the bed or extruder temperature changes 

186 # Consider only rising temperature (<), i.e., heating, not cooling 

187 if temps_prev.bed_temp < temps.bed_temp and temps_prev.extruder_temp == temps.extruder_temp: 

188 tau_bed.setdefault(temps.bed_temp, []).append(tau) 

189 if temps_prev.extruder_temp < temps.extruder_temp and temps_prev.bed_temp == temps.bed_temp: 

190 tau_extruder.setdefault(temps.extruder_temp, []).append(tau) 

191 

192 temps_prev = temps 

193 

194 logger.debug(f"bed time constant data={tau_bed}") 

195 logger.debug(f"extruder time constant data={tau_extruder}") 

196 

197 # Get average of all key values in tau_bed_stats 

198 time_constant_bed = self._analyse_temp(temp_offsets=tau_bed) 

199 time_constant_extruder = self._analyse_temp(temp_offsets=tau_extruder) 

200 

201 return CalibrationData( 

202 offset_data=OffsetData(offset_temps), 

203 time_constant_bed=time_constant_bed, 

204 time_constant_extruder=time_constant_extruder, 

205 frame_expansion_coef=float('NaN'))