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
« 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
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
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
20logger = logging.getLogger(__name__)
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.
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 """
33 @staticmethod
34 def klipper_config(config: ConfigWrapper) -> RunConfig:
35 """Build the RunConfig from the klipper configuration file.
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 )
53 gcmd: Gcmd
54 run_config: RunConfig
55 klipper_module: KlipperModule
56 temp_step: int = field(init=False, default=0)
58 def run(self) -> CalibrationData:
59 """Start the calibration process.
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")
68 for gcode in self.run_config.pre_run_gcode:
69 self.klipper_module.gcode.run_script_from_command(gcode)
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)
76 # Show and store the results
77 time_end = self.klipper_module.get_time()
79 logger.info(f"Duration={(time_end - time_start):.1f} s")
81 return calibration_data
83 finally:
84 # Turn heating off
85 self.klipper_module.set_temperatures(gcmd=self.gcmd, temps=Temps.klipper(0.0, 0.0))
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
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)
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)
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)
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.
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.
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
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()
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
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)
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)
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.
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
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
170 def analyse(self, offset_temps: list[OffsetTemp]) -> CalibrationData:
171 """Analyze the Z-offset measurements.
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
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)
192 temps_prev = temps
194 logger.debug(f"bed time constant data={tau_bed}")
195 logger.debug(f"extruder time constant data={tau_extruder}")
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)
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'))