diff --git a/.gitignore b/.gitignore index 56bea6b..f7ae84d 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,4 @@ cython_debug/ /.idea/ config.ini /rcp/settings/ +.claude/settings.local.json diff --git a/pyproject.toml b/pyproject.toml index dddfc16..134b8c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rcp" -version = "1.3.0-rc.21" +version = "1.3.0-rc.25" description = "Rotary Controller Python" authors = [ { name = "Stefano Bertelli", email = "stefano@provvedo.com" } diff --git a/rcp/components/home/assisted_threading/__init__.py b/rcp/components/home/assisted_threading/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rcp/components/home/assisted_threading/bar.kv b/rcp/components/home/assisted_threading/bar.kv new file mode 100644 index 0000000..fb6fc15 --- /dev/null +++ b/rcp/components/home/assisted_threading/bar.kv @@ -0,0 +1,72 @@ +: + orientation: "horizontal" + size_hint_y: None + height: 128 + + Button: + width: 96 + size_hint_x: None + text: "Stop" if root.is_running else "Start" + font_size: self.height / 4 + font_style: "bold" + background_color: [1, 0.2, 0.2, 1] if root.is_running else [0.3, 0.3, 0.3, 1] + on_release: root.toggle_is_running() + + BoxLayout: + id: wizard_area + orientation: "vertical" + size_hint_x: 0.8 + + # Default display when NOT running + Label: + id: label_instruction + text: root.label_text + text_size: self.size + size_hint_y: 0.15 + font_size: 16 + color: app.display_color + halign: 'center' + valign: 'top' + + Button: + id: btn_value + size_hint_y: 0.30 + font_name: "fonts/iosevka-regular.ttf" + font_size: self.height / 2 + font_style: "bold" + background_color: [0.2, 0.2, 0.2, 1] + color: app.formats.display_color + text: root.display_value + text_size: self.size + halign: 'center' + valign: 'middle' + + ProgressBar: + id: progress_servo + size_hint_y: 0.20 + max: int(app.servo.maxSpeed) + value: int(abs(app.servo.speed)) + + Button: + id: btn_action + width: self.height + size_hint_x: None + font_name: "fonts/iosevka-regular.ttf" if root.is_running else "fonts/Font Awesome 6 Free-Solid-900.otf" + text: root.next_button_text if root.is_running else "\uf013" + font_size: self.height / 4 + halign: "center" + on_release: root.on_action_button_clicked() + disabled: not root.action_button_enabled + + HoldButton: + id: btn_retract + font_name: "fonts/iosevka-regular.ttf" + size_hint_x: None if root.retract_button_visible else 0 + width: self.height if root.retract_button_visible else 0 + opacity: 1 if root.retract_button_visible else 0 + disabled: not root.retract_button_enabled + text: 'Retract' + font_size: self.height / 4 + halign: "center" + on_press: root.on_retract_button_pressed() + on_release: root.on_retract_button_released() diff --git a/rcp/components/home/assisted_threading/bar.py b/rcp/components/home/assisted_threading/bar.py new file mode 100644 index 0000000..9a382b7 --- /dev/null +++ b/rcp/components/home/assisted_threading/bar.py @@ -0,0 +1,238 @@ +from kivy.logger import Logger +from kivy.uix.boxlayout import BoxLayout +from kivy.properties import NumericProperty, BooleanProperty, StringProperty + +from rcp import feeds +from rcp.components.widgets.custom_popup import CustomPopup +from rcp.components.widgets.hold_button import HoldButton +from rcp.components.home.assisted_threading.wizard import AssistedThreadingWizard +from rcp.components.home.assisted_threading.thread_type import ThreadType +from rcp.dispatchers.saving_dispatcher import SavingDispatcher +from rcp.utils.kv_loader import load_kv + +log = Logger.getChild(__name__) + +load_kv(__file__) + +class AssistedThreadingBar(BoxLayout, SavingDispatcher): + # ── Per-job thread settings (saved on the bar) ──────────────────── + metric_mode = BooleanProperty(True) # This is for the actual threading logic + selected_pitch = StringProperty("") + current_feeds_index = NumericProperty(0) + thread_profile_type = StringProperty("ISO_METRIC") + shaft_diameter = NumericProperty(1) + left_hand_thread = BooleanProperty(False) + inner_thread = BooleanProperty(False) + + compound_infeed_mode = BooleanProperty(False) + compound_infeed_offset_degrees = NumericProperty(1.0) + + is_running = BooleanProperty(False) + action_button_enabled = BooleanProperty(True) + label_text = StringProperty("") + display_value = StringProperty("") + next_button_text = StringProperty("") + start_position = NumericProperty(0) + stop_position = NumericProperty(0) + material_width = NumericProperty(0) + cutting_depth = NumericProperty(0) + last_cutting_depth = NumericProperty(0) + retract_button_visible = BooleanProperty(False) + retract_button_enabled = BooleanProperty(True) + _skip_save = [ + "is_running", + "action_button_enabled", + "label_text", + "display_value", + "start_position", + "stop_position", + "material_width", + "cutting_depth", + "last_cutting_depth", + "retract_button_visible", + "retract_button_enabled" + ] + + def __init__(self, **kv): + from rcp.app import MainApp + self.app: MainApp = MainApp.get_running_app() + self.action_button_condition_fn = None + self.retract_button_condition_fn = None + super().__init__(**kv) + + self.current_feeds_table = feeds.table["Thread MM"] if self.metric_mode else feeds.table["Thread IN"] + self.update_feeds_ratio(self, None) + + # Initialize with default thread type if not set + if not self.thread_profile_type: + self.thread_profile_type = ThreadType.ISO_METRIC.value + self.wizard = AssistedThreadingWizard(self) + + self.app.bind(current_mode=self.on_mode_change) + self.bind(left_hand_thread=self.update_feeds_ratio) + + def toggle_is_running(self): + if not self.is_running: + missing = [] + if self.app.els.get_spindle_axis() is None: + missing.append("Spindle") + if self.app.els.get_z_axis() is None: + missing.append("Saddle (Z)") + if self.app.els.get_x_axis() is None: + missing.append("Cross-slide (X)") + if missing: + CustomPopup( + title="Axes Not Configured", + message=f"The following axes are not set in ELS: {', '.join(missing)}. Please configure them in Settings.", + button_text="OK", + ).open() + return + self.is_running = not self.is_running + if self.is_running: + self.wizard.start() + else: + self.stop_wizard() + + def stop_wizard(self): + self.wizard.stop() + + def on_metric_mode(self, instance, value): + self.current_feeds_table = feeds.table["Thread MM"] if value else feeds.table["Thread IN"] + + def on_mode_change(self, instance, mode): + if mode == 5: # AT mode + self.update_feeds_ratio(None, None) + + def on_retract_button_pressed(self): + """Called when the retract button is pressed.""" + if not self.retract_button_enabled: + return + self.wizard.start_retracting() + + def on_retract_button_released(self): + """Called when the retract button is released.""" + if not self.retract_button_enabled: + return + self.wizard.stop_retracting() + + def on_action_button_clicked(self): + """Called when the right button is pressed.""" + if self.is_running: + self.wizard.goto_next_step() + else: + self.open_settings() + + def update_feeds_ratio(self, instance, value): + if self.app.current_mode != 5: + return # only sync in AT mode + + ratio = self.current_feeds_table[self.current_feeds_index].ratio + spindle_axis = self.app.els.get_spindle_axis() + if spindle_axis is not None: + direction = -1 if self.left_hand_thread else 1 + spindle_axis.syncRatioNum = ratio.numerator * direction + spindle_axis.syncRatioDen = ratio.denominator + log.info(f"Configured ratio is: {ratio.numerator}/{ratio.denominator}, left_hand_thread={self.left_hand_thread}") + + def open_settings(self): + from rcp.components.home.assisted_threading.settings_popup import AssistedThreadingSettingsPopup + popup = AssistedThreadingSettingsPopup(assistedThreadingBar=self) + popup.open() + + def bind_display_value_to_scale(self, axis): + """Bind display_value to an AxisDispatcher's formattedPosition with strict keypad override support.""" + + # Unbind any previous bindings + self.unbind_all_display_value() + + # Store the axis (AxisDispatcher) for later unbind + self._bound_scale = axis + inp = axis._primary_input() if axis is not None else None + + # --- Encoder update handler (fires on raw encoder tick) --- + def on_encoder_update(*_): + # Cancel manual override if the encoder moves + if self.wizard and self.wizard.manual_stop_length is not None: + log.info("Scale encoder moved — discarding manual stop length override") + self.wizard.manual_stop_length = None + # Display the axis formatted position (not raw encoder ticks) + self.display_value = axis.formattedPosition + self.update_buttons_state() + + # --- Format update handler --- + def on_format_update(instance, value): + # Only update display if NOT in manual override + if not (self.wizard and self.wizard.manual_stop_length is not None): + self.display_value = value + + # Keep references so we can unbind later + self._on_encoder_update = on_encoder_update + self._on_format_update = on_format_update + + # encoderCurrent lives on InputDispatcher; formattedPosition on AxisDispatcher + if inp is not None: + inp.bind(encoderCurrent=on_encoder_update) + axis.bind(formattedPosition=on_format_update) + + # Initial display + self.display_value = axis.formattedPosition + + def bind_display_value_to_servo_position(self): + """Bind display_value to the servo's formattedPosition.""" + # Unbind any previous bindings + self.unbind_all_display_value() + self._bound_servo = self.app.servo + + def on_servo_position_update(instance, value): + self.display_value = value + + self._on_servo_position_update = on_servo_position_update + + # Bind to servo's formattedPosition + self.app.servo.bind(formattedPosition=on_servo_position_update) + + def bind_btn_value_on_release(self, on_release_fn): + """Bind the value button to a function.""" + # Unbind old function if it exists + if hasattr(self, "_on_value_button_release") and self._on_value_button_release is not None: + self.ids.btn_value.unbind(on_release=self._on_value_button_release) + + # Store the binding function + self._on_value_button_release = on_release_fn + + if(on_release_fn is None): + # If None is passed, disable the button + self.ids.btn_value.disabled = True + return + + self.ids.btn_value.disabled = False + # Bind the new function + self.ids.btn_value.bind(on_release=on_release_fn) + + def unbind_all_display_value(self): + if hasattr(self, "_bound_scale") and self._bound_scale is not None: + inp = self._bound_scale._primary_input() + if inp is not None: + inp.unbind(encoderCurrent=self._on_encoder_update) + self._bound_scale.unbind(formattedPosition=self._on_format_update) + self._bound_scale = None + if hasattr(self, "_bound_servo") and self._bound_servo is not None: + self._bound_servo.unbind(formattedPosition=self._on_servo_position_update) + self._bound_servo = None + # Unbind threading progress display if it was bound + if hasattr(self.wizard, "_progress_display_scale") and self.wizard._progress_display_scale is not None: + if hasattr(self.wizard, "_on_threading_progress_update"): + self.wizard._progress_display_scale.unbind(encoderCurrent=self.wizard._on_threading_progress_update) + self.wizard._progress_display_scale = None + + def update_buttons_state(self): + """Evaluate whether the action/retract buttons should be enabled.""" + if self.action_button_condition_fn: + self.action_button_enabled = self.action_button_condition_fn() + else: + self.action_button_enabled = True + + if self.retract_button_condition_fn: + self.retract_button_enabled = self.retract_button_condition_fn() + else: + self.retract_button_enabled = True diff --git a/rcp/components/home/assisted_threading/calculations.py b/rcp/components/home/assisted_threading/calculations.py new file mode 100644 index 0000000..a28a336 --- /dev/null +++ b/rcp/components/home/assisted_threading/calculations.py @@ -0,0 +1,325 @@ +from fractions import Fraction +from math import radians, tan + +from kivy.logger import Logger + +from rcp.components.home.assisted_threading.thread_type import ThreadType + +log = Logger.getChild(__name__) + +MM_PER_INCH = 25.4 + +# Thread half-angles in degrees, keyed by ThreadType +_THREAD_HALF_ANGLES: dict[ThreadType, float] = { + ThreadType.ISO_METRIC: 30.0, + ThreadType.UNIFIED: 30.0, + ThreadType.WHITWORTH: 27.5, + ThreadType.ACME: 14.5, +} + + +class AssistedThreadingCalculationsMixin: + # --------------------------------------------------------------------------- + # Direction helpers + # --------------------------------------------------------------------------- + + def _get_cross_slide_scale_effective_dir(self) -> int: + """Get the cross slide effective direction, considering thread type (internal/external) and scale direction.""" + # Physical cutting direction: internal → outward (+), external → inward (-) + thread_dir = 1 if self.bar.inner_thread else -1 + + # Encoder direction: positive if scale ratio is positive, negative if reversed + scale_dir = 1 if self.cross_slide_input.ratioNum * self.cross_slide_input.ratioDen > 0 else -1 + + # Combined effective direction + return thread_dir * scale_dir + + def _get_saddle_scale_effective_dir(self) -> int: + """Get the saddle scale effective direction, considering if it's left/right hand tread and scale direction.""" + # Thread direction: LH → +, RH → - + thread_dir = 1 if self.bar.left_hand_thread else -1 + + # Scale direction from ratio sign + scale_dir = 1 if self.saddle_input.ratioNum * self.saddle_input.ratioDen > 0 else -1 + + return thread_dir * scale_dir + + # --------------------------------------------------------------------------- + # Unit conversion + # --------------------------------------------------------------------------- + + def _convert_distance_units_to_encoder(self, scale, distance: float, is_metric: bool) -> int: + """ + Convert a pure distance (mm or inch) into encoder counts. + scale: AxisDispatcher + """ + inp = scale._primary_input() + encoder_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) + + # Pure distance conversion — offsets do not apply (those are DRO zero offsets for positions, not distances) + encoder_counts = (distance / encoder_factor) * (float(inp.ratioDen) / float(inp.ratioNum)) + + final_encoder_distance = int(round(encoder_counts)) + + log.info( + f"Converted distance to encoder counts: {final_encoder_distance} " + f"(input distance={distance}, encoder delta={encoder_counts})" + ) + + return final_encoder_distance + + def _convert_position_units_to_encoder(self, + scale, + manual_position: float, + is_original_position_metric_mode: bool, + original_scaled_position, + start_encoder_units: int) -> int: + """ + Convert a user-entered stop position (MM/IN) into encoder counts. + Handles: + - unit changes (MM ↔ IN) + - offsets + - zero start positions + """ + + # Determine factors + current_factor = float(self.app.formats.factor) + factor_at_start_position = float(self.app.formats.MM_FRACTION if is_original_position_metric_mode else self.app.formats.INCHES_FRACTION) + + # Normalize manual input to the units used at start + manual_in_start_units = manual_position * (factor_at_start_position / current_factor) + + # Compute delta relative to start scaled position + delta_in_start_units = manual_in_start_units - original_scaled_position + + log.info( + f"Manual input: {manual_position} " + f"(converted to start units: {manual_in_start_units}, " + f"delta from start: {delta_in_start_units})" + ) + + # delta_in_start_units is already relative to the start position — offsets do not apply + inp = scale._primary_input() + encoder_counts = (delta_in_start_units / factor_at_start_position) * (float(inp.ratioDen) / float(inp.ratioNum)) + + # Offset by the captured start position + final_encoder_position = int(round(start_encoder_units + encoder_counts)) + + log.info( + f"Computed encoder counts: {final_encoder_position} " + f"(start_position={start_encoder_units}, encoder delta={encoder_counts})" + ) + + return final_encoder_position + + def _get_stop_position_units(self) -> float: + scale = self.saddle_scale + if self.manual_stop_length is not None: + log.info(f"Using manual stop length: {self.manual_stop_length}") + result = self._convert_position_units_to_encoder( + scale, + self.manual_stop_length, + self._isStartPositionMetricMode, + self._startScaledPosition, + self.bar.start_position + ) + log.info(f"Converted manual stop length to encoder units: {result}") + return result + log.info(f"Using live encoder value: {self.saddle_input.encoderCurrent}") + return self.saddle_input.encoderCurrent + + # --------------------------------------------------------------------------- + # Backlash distances + # --------------------------------------------------------------------------- + + def _get_saddle_backlash_distance_encoder_steps(self) -> int: + """Get the retraction distance in encoder counts.""" + return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_saddle_backlash_distance, self.app.els.at_metric_distances) + + def _get_backlash_cusion_encoder_steps(self) -> int: + """Get the backlash cushion distance in encoder counts.""" + return self._convert_distance_units_to_encoder(self.saddle_scale, self.app.els.at_backlash_cushion, self.app.els.at_metric_distances) + + # --------------------------------------------------------------------------- + # Threading servo delta + # --------------------------------------------------------------------------- + + def _get_threading_servo_delta_steps(self) -> int: + """ + Compute the servo step delta needed to move the saddle + from the current position to the stop position + in the cutting direction. + """ + + effective_dir = self._get_saddle_scale_effective_dir() + + current_encoder = self.saddle_input.encoderCurrent + target_encoder = self.bar.stop_position + + delta_enc = target_encoder - current_encoder + if delta_enc * effective_dir <= 0: + log.warning( + "Threading delta is opposite to effective cutting direction " + f"(current={current_encoder}, stop={target_encoder}, " + f"effective_dir={effective_dir})" + ) + + # Convert encoder delta → servo steps + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) + + delta_steps = int(delta_enc * scale_ratio / servo_ratio) + + log.info( + f"Computed threading servo delta: {delta_steps} steps " + f"(current_enc={current_encoder}, stop_enc={target_encoder}, " + f"delta_enc={delta_enc}, " + f"scale_ratio={scale_ratio}, servo_ratio={servo_ratio}, " + f"effective_dir={effective_dir})" + ) + + return delta_steps + + # --------------------------------------------------------------------------- + # Thread depth calculation + # --------------------------------------------------------------------------- + + def _calculate_thread_depth(self): + """ + Calculate thread depth based on selected pitch and thread profile type. + + Uses metric_mode to determine if selected_pitch is in mm or TPI. + Formulas provided are for radial depth; multiply by 2 if diameter mode is enabled. + + Returns: + Thread depth in the selected units (mm or inches), or None if invalid + """ + if not self.bar.selected_pitch: + log.warning("No pitch selected for depth calculation") + return None + + # Determine effective pitch based on metric_mode + try: + if self.bar.metric_mode: + # In metric mode, selected_pitch is the pitch in mm + pitch = float(self.bar.selected_pitch) + else: + # In imperial mode, selected_pitch is TPI (threads per inch) + # Convert TPI to pitch in inches + tpi = float(self.bar.selected_pitch) + pitch = MM_PER_INCH / tpi + except (ValueError, TypeError): + log.warning(f"Could not parse pitch from: {self.bar.selected_pitch}") + return None + + if pitch <= 0: + log.warning(f"Invalid pitch value: {pitch}") + return None + + # Determine thread profile and calculate radial depth + thread_type = ThreadType(self.bar.thread_profile_type) + + if thread_type == ThreadType.ISO_METRIC: + # ISO 68-1 (60°): H = (√3/2) * pitch; + # actual thread depth ≈ 0.61343 * pitch + depth = 0.61343 * pitch + + elif thread_type == ThreadType.UNIFIED: + # ASME B1.1 (60°): H = (√3/2) * pitch; + # actual thread depth from truncation ≈ 0.64952 * pitch + depth = 0.64952 * pitch + + elif thread_type == ThreadType.WHITWORTH: + # BSW (55°): rounded crest/root; + # theoretical H ≈ 0.9605 * pitch; + # actual thread depth ≈ 0.6403 * pitch + depth = 0.6403 * pitch + + elif thread_type == ThreadType.ACME: + # ASME B1.5 (29°): trapezoidal profile; + # basic thread height ≈ 0.5 * pitch + depth = 0.5 * pitch + + else: + log.warning(f"Unknown thread profile: {thread_type}") + return None + + # Account for cross-slide diameter mode + # Formulas are for radial depth; in diameter mode multiply by 2 + if self.app.els.at_cross_slide_diameter_mode: + depth = depth * 2 + + # depth is always in mm at this point; convert to display units if needed + is_current_format_metric = self.app.formats.current_format == "MM" + if not is_current_format_metric: + depth = depth / MM_PER_INCH + + log.info(f"Calculated thread depth: {depth:.4f} (pitch={pitch:.4f}, type={thread_type}, metric_mode={self.bar.metric_mode}, current_format={'MM' if is_current_format_metric else 'IN'}, diameter_mode={self.app.els.at_cross_slide_diameter_mode})") + return depth + + # --------------------------------------------------------------------------- + # Compound infeed + # --------------------------------------------------------------------------- + + def _get_compound_angle_degrees(self) -> float: + """ + Return the effective compound angle in degrees for the selected thread type. + = thread half-angle − user-configured offset (default 1°). + """ + try: + thread_type = ThreadType(self.bar.thread_profile_type) + except ValueError: + log.warning(f"Unknown thread profile for compound angle: {self.bar.thread_profile_type}") + return 29.0 # safe fallback (ISO metric at 1° offset) + + half_angle = _THREAD_HALF_ANGLES.get(thread_type, 30.0) + offset = float(self.bar.compound_infeed_offset_degrees) + effective = half_angle - offset + log.info(f"Compound angle: half_angle={half_angle}°, offset={offset}°, effective={effective}° (thread_type={thread_type})") + return effective + + def _get_compound_z_offset_encoder(self) -> int: + """ + Compute the saddle (Z) encoder shift for compound infeed. + + ΔZ = ΔX_mm × tan(compound_angle) + + ΔX is the physical depth from the material surface in the cutting direction. + Uses _get_cross_slide_scale_effective_dir() so the sign correctly reflects + whether the tool is cutting (positive) or retracted past the surface (≤ 0). + Returns 0 when the cross-slide is at or retracted past the material surface. + Direction of the Z shift is applied by the caller via _get_saddle_scale_effective_dir(). + """ + if not self.bar.compound_infeed_mode: + return 0 + + cross_dir = self._get_cross_slide_scale_effective_dir() + cross_inp = self.cross_slide_input + + # Positive = tool is deeper than surface (cutting). Zero/negative = at surface or retracted. + delta_x_enc_in_cut_dir = (cross_inp.encoderCurrent - self.bar.material_width) * cross_dir + if delta_x_enc_in_cut_dir <= 0: + return 0 + + # Convert X encoder depth to mm. + # cross_dir already accounts for ratioNum sign, so use abs(ratioNum/ratioDen) for magnitude. + encoder_factor = float(self.app.formats.MM_FRACTION) + delta_x_mm = delta_x_enc_in_cut_dir * encoder_factor * abs(float(cross_inp.ratioNum) / float(cross_inp.ratioDen)) if cross_inp.ratioDen != 0 else 0.0 + + # Compound formula requires radial depth; in diameter mode MM_FRACTION includes 2× multiplier + if self.app.els.at_cross_slide_diameter_mode: + delta_x_mm /= 2.0 + + compound_angle = self._get_compound_angle_degrees() + delta_z_mm = delta_x_mm * tan(radians(compound_angle)) + + # abs() because _convert_distance_units_to_encoder is signed (reflects scale ratioNum sign). + # We return a positive magnitude; direction is applied by the caller via _get_saddle_scale_effective_dir(). + z_encoder = abs(self._convert_distance_units_to_encoder(self.saddle_scale, delta_z_mm, is_metric=True)) + + log.info( + f"Compound Z offset: delta_x_enc_in_cut_dir={delta_x_enc_in_cut_dir}, delta_x_mm={delta_x_mm:.4f}, " + f"compound_angle={compound_angle:.2f}°, delta_z_mm={delta_z_mm:.4f}, " + f"z_encoder={z_encoder}" + ) + return z_encoder diff --git a/rcp/components/home/assisted_threading/motion.py b/rcp/components/home/assisted_threading/motion.py new file mode 100644 index 0000000..a4e5e63 --- /dev/null +++ b/rcp/components/home/assisted_threading/motion.py @@ -0,0 +1,177 @@ +from fractions import Fraction + +from kivy.logger import Logger + +log = Logger.getChild(__name__) + + +class GoToStartPhase: + IDLE = 0 + RETRACT = 1 + PRELOAD = 2 + ADJUST = 3 + + +class AssistedThreadingMotionMixin: + # --------------------------------------------------------------------------- + # Step 6 callback — go to start (motion logic only) + # --------------------------------------------------------------------------- + + def _go_to_start(self, *args): + if not self.app.board.connected: + self.stop() + return False + + self.bar.retract_button_enabled = False # Disable retract button during move to start + self.bar.action_button_enabled = False # Disable action button during move to start + + self._apply_reversing_adjusting_acceleration() + self._start_position_preloaded = False + self._goto_start_phase = GoToStartPhase.RETRACT + + effective_dir = self._get_saddle_scale_effective_dir() + + retraction = abs(self._get_saddle_backlash_distance_encoder_steps() * 1.5) # retract 1.5x backlash distance + retraction_dir = -effective_dir # retract opposite to cutting direction + log.info(f"Starting retract to go to start: effective_dir={effective_dir}, retraction={retraction}, retraction_dir={retraction_dir}") + retract_target = self.bar.start_position + retraction_dir * retraction + + self._command_move_to_encoder(retract_target, speed=self.app.els.at_reversing_speed) + + self._servo_watch_callback = self._watch_go_to_start + self.app.board.bind(update_tick=self._servo_watch_callback) + + return False + + # --------------------------------------------------------------------------- + # Low-level move command + # --------------------------------------------------------------------------- + + def _command_move_to_encoder(self, target_encoder, speed): + self._reset_encoder_stability_check() + + current_enc = self.saddle_input.encoderCurrent + + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) + + delta = int((target_encoder - current_enc) * scale_ratio / servo_ratio) + + log.info( + f"Move to encoder: current={current_enc}, " + f"target={target_encoder}, delta={delta}" + ) + + self.bar.bind_display_value_to_servo_position() + self.servo.set_max_speed(speed) + self.app.board.device['servo']['direction'] = delta + + # --------------------------------------------------------------------------- + # Watch callbacks (polled on update_tick) + # --------------------------------------------------------------------------- + + def _watch_retracting_stopped(self, *_): + if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): + return + + self._reset_servo_watch_callback() + self.servo.set_max_speed(self.servo.maxSpeed) + self.servo.servoEnable = 1 # back to normal servo mode + + self.goto_step(5) # Go back to step 6 - Go to start position + + def _watch_go_to_start(self, *_): + if not self._motion_complete(): + return + + if self._goto_start_phase == GoToStartPhase.RETRACT: + self._start_preload_move() + + elif self._goto_start_phase == GoToStartPhase.PRELOAD: + self._start_adjust_move() + + elif self._goto_start_phase == GoToStartPhase.ADJUST: + self._finish_go_to_start() + + # --------------------------------------------------------------------------- + # Encoder stability check + # --------------------------------------------------------------------------- + + def _reset_encoder_stability_check(self): + self._last_saddle_encoder_value = None + self._stable_count = 0 + + def _encoder_is_stable(self, tolerance, samples): + current = self.saddle_input.encoderCurrent + + if self._last_saddle_encoder_value is None: + self._last_saddle_encoder_value = current + self._stable_count = 0 + return False + + if abs(current - self._last_saddle_encoder_value) <= tolerance: + self._stable_count += 1 + else: + self._stable_count = 0 + + self._last_saddle_encoder_value = current + + return self._stable_count >= samples + + def _motion_complete(self): + if self.app.board.fast_data_values['stepsToGo'] != 0: + return False + + if not self._encoder_is_stable(self.app.els.at_saddle_encoder_stability_tolerance, self.app.els.at_saddle_encoder_stability_samples): + return False + + return True + + # --------------------------------------------------------------------------- + # Preload / adjust / finish phases + # --------------------------------------------------------------------------- + + def _start_preload_move(self): + self._reset_servo_watch_callback() + self._goto_start_phase = GoToStartPhase.PRELOAD + + log.info("Retract complete, starting preload move") + backlash_preload_steps = int(abs(self._get_saddle_backlash_distance_encoder_steps()) * 1.25) # preload 1.25x backlash distance - before we retracted 1.5x so we have some cushion + preload_target = self.saddle_input.encoderCurrent + self._get_saddle_scale_effective_dir() * backlash_preload_steps + + self._apply_reversing_adjusting_acceleration() + self._command_move_to_encoder( + preload_target, + speed=self.app.els.at_preload_adjust_speed + ) + + self._servo_watch_callback = self._watch_go_to_start + self.app.board.bind(update_tick=self._servo_watch_callback) + + def _start_adjust_move(self): + self._reset_servo_watch_callback() + self._goto_start_phase = GoToStartPhase.ADJUST + + log.info("Preload move complete, starting final adjust move") + + self._apply_reversing_adjusting_acceleration() + self._command_move_to_encoder( + self.bar.start_position, + speed=self.app.els.at_preload_adjust_speed + ) + + self._servo_watch_callback = self._watch_go_to_start + self.app.board.bind(update_tick=self._servo_watch_callback) + + def _finish_go_to_start(self): + self._reset_servo_watch_callback() + + log.info("Start position reached with backlash preloaded") + + self._start_position_preloaded = True + + next_step = self.current_step + 1 + if self._is_cross_slide_at_final_cutting_depth(): + next_step += 1 + + self.goto_step(next_step) diff --git a/rcp/components/home/assisted_threading/safety.py b/rcp/components/home/assisted_threading/safety.py new file mode 100644 index 0000000..4df7a5f --- /dev/null +++ b/rcp/components/home/assisted_threading/safety.py @@ -0,0 +1,209 @@ +from kivy.logger import Logger + +from rcp.components.home.assisted_threading.calculations import MM_PER_INCH +from rcp.components.widgets.custom_popup import CustomPopup +from rcp.utils.devices import SCALES_COUNT + +log = Logger.getChild(__name__) + + +class AssistedThreadingSafetyMixin: + # --------------------------------------------------------------------------- + # Button condition functions (enable/disable action buttons) + # --------------------------------------------------------------------------- + + def _is_valid_stop_position(self): + """Check if the stop position is valid given the start position and thread direction. + - For right-hand threads, stop must be less than start. + - For left-hand threads, stop must be greater than start. + - Stop position must be greater than the backlash cushion distance from start position - if stop is too small, the saddle may not have enough room to cut properly. + - Depending on sign of the scale ratioNum/ratioDen, this will also affect the calculation""" + + effective_dir = self._get_saddle_scale_effective_dir() + backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) + stop = self._get_stop_position_units() + min_stop = self.bar.start_position + effective_dir * backlash_cushion + return (stop - min_stop) * effective_dir > 0 + + def _is_cross_slide_retracted(self): + """ + Check if the cross slide is safely retracted when the saddle has moved beyond the threading start position. + """ + log.debug("Checking if cross slide is retracted for threading start...") + + # --- Saddle direction check (Z axis) --- + saddle_dir = self._get_saddle_scale_effective_dir() + + saddle_delta = self.saddle_input.encoderCurrent - self.bar.start_position + saddle_beyond_start = saddle_delta * saddle_dir > 0 + + if not saddle_beyond_start: + log.debug("Saddle is not beyond start position, no need to check cross slide") + return True + + log.debug("Saddle is beyond start position, checking cross slide retraction") + + # --- Cross-slide retraction check (X axis) --- + retract_dir = -self._get_cross_slide_scale_effective_dir() + + cross_delta = self.cross_slide_input.encoderCurrent - self.bar.material_width + return cross_delta * retract_dir > 0 + + def _is_cross_slide_at_final_cutting_depth(self): + """Check if the cross slide is at or more than the final cutting depth position.""" + effective_dir = self._get_cross_slide_scale_effective_dir() + current = self.cross_slide_input.encoderCurrent + log.info(f"Checking if at cutting depth: last_cutting_depth={self.bar.last_cutting_depth}, cutting_depth={self.bar.cutting_depth}, effective_dir={effective_dir}") + return (self.bar.last_cutting_depth - self.bar.cutting_depth) * effective_dir >= 0 + + # --------------------------------------------------------------------------- + # Pre-threading safety checks + # --------------------------------------------------------------------------- + + def _check_saddle_not_past_stop(self) -> bool: + """Return True if the saddle has not moved past (or to) the stop position. + Protects against the compound infeed ΔZ shift consuming all remaining thread length. + Shows a warning popup and redirects to step 6 if not.""" + effective_dir = self._get_saddle_scale_effective_dir() + current_saddle = self.saddle_input.encoderCurrent + remaining = (self.bar.stop_position - current_saddle) * effective_dir + if remaining <= 0: + log.warning( + f"Saddle at or past stop position " + f"(current={current_saddle}, stop={self.bar.stop_position}, " + f"effective_dir={effective_dir}) — aborting threading" + ) + CustomPopup( + title="Warning", + message="Compound infeed shift has consumed the remaining thread length. " + "Reduce infeed depth or increase thread start clearance.", + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + + def _check_valid_start_position(self) -> bool: + """Return True if the saddle is within the backlash cushion of the start position. + Shows a warning popup and redirects to step 6 if not. Sanity check in case the + start_position_preloaded flag was bypassed or the saddle moved after preload.""" + backlash_cushion = abs(self._get_backlash_cusion_encoder_steps()) + log.info( + f"Validating start position: current={self.saddle_input.encoderCurrent}, " + f"start={self.bar.start_position}, " + f"backlash_cushion={backlash_cushion}" + ) + delta = abs(self.saddle_input.encoderCurrent - self.bar.start_position) + if delta > backlash_cushion: + message = ( + "Not at valid start position including backlash cushion. " + "Aborting threading operation. Go back to start position." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + + def _check_spindle_turning_forward(self) -> bool: + """Return True if the spindle scale exists and is turning in the right/positive/CCW direction. + Shows a warning popup and redirects to step 6 if not.""" + spindle_axis = self.app.els.get_spindle_axis() + spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None + if spindle_inp is None: + log.warning("No spindle scale configured — cannot verify spindle direction") + CustomPopup( + title="Warning", + message="No spindle scale configured. Cannot verify spindle is turning.", + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + + spindle_speed = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] + log.info(f"Validating spindle direction: scaleSpeed[{spindle_inp.inputIndex}]={spindle_speed}") + + if spindle_speed <= 0: + message = ( + "Spindle is not turning in the right/positive/CCW direction. " + "Ensure the spindle is running forward before starting the threading operation." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True + + def _check_spindle_speed_for_pitch(self) -> bool: + """Return True if the current spindle RPM is within the servo's speed limit + for the selected pitch. Shows a warning popup and redirects to step 6 if not.""" + from fractions import Fraction + + spindle_axis = self.app.els.get_spindle_axis() + spindle_inp = spindle_axis._primary_input() if spindle_axis is not None else None + if spindle_inp is None: + return True # already caught by _check_spindle_turning_forward + + spindle_steps_per_sec = self.app.board.fast_data_values.get('scaleSpeed', [0] * SCALES_COUNT)[spindle_inp.inputIndex] + + try: + pitch_str = self.bar.selected_pitch.strip() + if not pitch_str: + return True # no pitch selected yet — skip + pitch_val = float(pitch_str) + except ValueError: + log.warning(f"Cannot parse selected_pitch={self.bar.selected_pitch!r} — skipping speed check") + return True + + if self.bar.metric_mode: + pitch_mm = pitch_val + else: + if pitch_val == 0: + return True + pitch_mm = MM_PER_INCH / pitch_val # TPI → mm/rev + + spindle_rev_per_sec = spindle_steps_per_sec / spindle_inp.ratioDen + feed_mm_per_sec = spindle_rev_per_sec * pitch_mm + encoder_steps_per_sec = feed_mm_per_sec * self.saddle_input.stepsPerMM + + scale_ratio = Fraction(abs(self.saddle_input.ratioNum), abs(self.saddle_input.ratioDen)) + servo_ratio = Fraction(abs(self.servo.ratioNum), abs(self.servo.ratioDen)) + required = float(encoder_steps_per_sec * scale_ratio / servo_ratio) + + steps_per_mm_per_rev = pitch_mm * self.saddle_input.stepsPerMM * float(scale_ratio / servo_ratio) + max_rpm = (self.app.els.at_threading_max_speed / steps_per_mm_per_rev) * 60 if steps_per_mm_per_rev > 0 else 0 + + log.info( + f"Spindle speed check: spindle={spindle_steps_per_sec} steps/s, " + f"pitch={pitch_mm:.4f} mm, feed={feed_mm_per_sec:.4f} mm/s, " + f"required_servo={required:.1f} steps/s, max={self.app.els.at_threading_max_speed}, " + f"max_rpm={max_rpm:.1f}, greater={required > self.app.els.at_threading_max_speed}" + ) + + if required > self.app.els.at_threading_max_speed: + spindle_rpm = spindle_rev_per_sec * 60 + pitch_label = f"{pitch_mm:.3g} mm" if self.bar.metric_mode else f"{self.bar.selected_pitch} TPI" + message = ( + f"Spindle speed ({spindle_rpm:.0f} RPM) is too fast for {pitch_label} pitch. " + f"Required servo speed ({required:.0f} steps/s) exceeds the threading limit " + f"({self.app.els.at_threading_max_speed} steps/s). " + f"Max allowed spindle speed for this pitch is {max_rpm:.0f} RPM. " + "Reduce spindle speed or increase the threading max speed limit." + ) + log.warning(message) + CustomPopup( + title="Warning", + message=message, + button_text="Got it", + on_dismiss_callback=lambda: self.goto_step(5), + ).open() + return False + return True diff --git a/rcp/components/home/assisted_threading/settings_popup.kv b/rcp/components/home/assisted_threading/settings_popup.kv new file mode 100644 index 0000000..b3febcc --- /dev/null +++ b/rcp/components/home/assisted_threading/settings_popup.kv @@ -0,0 +1,82 @@ +: + title: "Assisted Threading Settings" + size_hint_x: 0.8 + size_hint_y: None + height: 450 + auto_dismiss: True + + ScrollView: + do_scroll_x: False + do_scroll_y: True + GridLayout: + id: grid_layout + cols: 1 + spacing: 1 + size_hint_y: None + height: self.minimum_height + + BooleanItem: + name: "Metric Mode" + help_file: "at_metric_mode.md" + value: root.assistedThreadingBar.metric_mode + on_value: root.on_metric_mode_changed(self.value) + + DropDownItem: + id: pitches_dropdown + height: 60 + name: "Pitch in MM" if root.assistedThreadingBar.metric_mode else "Pitch in IN" + help_file: "at_pitch.md" + options: root.get_pitches() + value: str(root.assistedThreadingBar.selected_pitch) if root.assistedThreadingBar.selected_pitch is not None else "" + on_value: root.on_pitch_selected(self.selected_index, self.value) + + DropDownItem: + id: thread_type_dropdown + height: 60 + name: "Thread Type" + help_file: "at_thread_type.md" + options: root.get_thread_types() + value: root.assistedThreadingBar.thread_profile_type + on_value: root.on_thread_type_selected(self.value) + + BooleanItem: + name: "Left Hand Thread" + help_file: "at_left_hand_thread.md" + value: root.assistedThreadingBar.left_hand_thread + on_value: root.assistedThreadingBar.left_hand_thread = self.value + + BooleanItem: + name: "Inner Thread" + help_file: "at_inner_thread.md" + value: root.assistedThreadingBar.inner_thread + on_value: root.assistedThreadingBar.inner_thread = self.value + + BooleanItem: + name: "Compound Infeed" + help_file: "at_compound_infeed.md" + value: root.assistedThreadingBar.compound_infeed_mode + on_value: root.on_compound_infeed_mode_changed(self.value) + + BoxLayout: + size_hint_y: None + height: 60 if root.assistedThreadingBar.compound_infeed_mode else 0 + opacity: 1 if root.assistedThreadingBar.compound_infeed_mode else 0 + disabled: not root.assistedThreadingBar.compound_infeed_mode + orientation: "horizontal" + Label: + size_hint_x: 0.45 + text: "Compound Offset" + font_name: "fonts/iosevka-regular.ttf" + font_size: 24 + Slider: + size_hint_x: 0.40 + min: 0 + max: 5 + step: 1 + value: root.assistedThreadingBar.compound_infeed_offset_degrees + on_value: root.assistedThreadingBar.compound_infeed_offset_degrees = self.value + Label: + size_hint_x: 0.15 + text: "{}°".format(int(root.assistedThreadingBar.compound_infeed_offset_degrees)) + font_name: "fonts/iosevka-regular.ttf" + font_size: 28 diff --git a/rcp/components/home/assisted_threading/settings_popup.py b/rcp/components/home/assisted_threading/settings_popup.py new file mode 100644 index 0000000..a9771d7 --- /dev/null +++ b/rcp/components/home/assisted_threading/settings_popup.py @@ -0,0 +1,69 @@ +from kivy.logger import Logger +from kivy.uix.popup import Popup +from kivy.properties import ObjectProperty + +from rcp.components.home.assisted_threading.thread_type import ThreadType +from rcp.utils.kv_loader import load_kv + +log = Logger.getChild(__name__) + +load_kv(__file__) + + +class AssistedThreadingSettingsPopup(Popup): + assistedThreadingBar = ObjectProperty(None) + + def __init__(self, **kv): + super().__init__(**kv) + + def get_pitches(self): + if not self.assistedThreadingBar: + return [] + + return [f.name for f in self.assistedThreadingBar.current_feeds_table] + + def get_thread_types(self): + """Get available thread types based on metric mode.""" + if self.assistedThreadingBar.metric_mode: + return [ThreadType.ISO_METRIC.value, ThreadType.ACME.value] + else: + return [ThreadType.UNIFIED.value, ThreadType.WHITWORTH.value, ThreadType.ACME.value] + + def on_metric_mode_changed(self, value): + self.assistedThreadingBar.metric_mode = value + pitches_dropdown = self.ids.pitches_dropdown + pitches = self.get_pitches() + pitches_dropdown.options = pitches + first_pitch = pitches[0] if pitches else "" + pitches_dropdown.value = first_pitch + self.on_pitch_selected(0, first_pitch) + + # Update thread type options based on metric mode + thread_type_dropdown = self.ids.thread_type_dropdown + thread_type_dropdown.options = self.get_thread_types() + # Reset to first available type + first_type = self.get_thread_types()[0] if self.get_thread_types() else ThreadType.ISO_METRIC.value + thread_type_dropdown.value = first_type + self.assistedThreadingBar.thread_profile_type = ThreadType(first_type).value + + log.info(f"Metric mode changed to: {value}") + + def on_pitch_selected(self, index, selected_pitch): + self.assistedThreadingBar.selected_pitch = selected_pitch + self.assistedThreadingBar.current_feeds_index = index + self.assistedThreadingBar.update_feeds_ratio(None,None) + log.info(f"Selected pitch: {selected_pitch}") + + def on_thread_type_selected(self, value): + """Handle thread type selection.""" + try: + # Convert string value back to ThreadType enum + thread_type = ThreadType(value) + self.assistedThreadingBar.thread_profile_type = thread_type.value + log.info(f"Selected thread type: {thread_type}") + except ValueError: + log.warning(f"Invalid thread type value: {value}") + + def on_compound_infeed_mode_changed(self, value): + self.assistedThreadingBar.compound_infeed_mode = value + log.info(f"Compound infeed mode changed to: {value}") diff --git a/rcp/components/home/assisted_threading/thread_type.py b/rcp/components/home/assisted_threading/thread_type.py new file mode 100644 index 0000000..690f764 --- /dev/null +++ b/rcp/components/home/assisted_threading/thread_type.py @@ -0,0 +1,9 @@ +from enum import StrEnum + + +class ThreadType(StrEnum): + """Thread profile types with their calculation formulas.""" + ISO_METRIC = "ISO Metric" + UNIFIED = "Unified" + WHITWORTH = "Whitworth" + ACME = "ACME" diff --git a/rcp/components/home/assisted_threading/wizard.py b/rcp/components/home/assisted_threading/wizard.py new file mode 100644 index 0000000..92c6899 --- /dev/null +++ b/rcp/components/home/assisted_threading/wizard.py @@ -0,0 +1,548 @@ +import logging +from fractions import Fraction + +from kivy.logger import Logger + +from rcp.components.home.assisted_threading.calculations import AssistedThreadingCalculationsMixin +from rcp.components.home.assisted_threading.motion import AssistedThreadingMotionMixin +from rcp.components.home.assisted_threading.safety import AssistedThreadingSafetyMixin + +log = Logger.getChild(__name__) + + +class AssistedThreadingWizard( + AssistedThreadingCalculationsMixin, + AssistedThreadingMotionMixin, + AssistedThreadingSafetyMixin, +): + # --------------------------------------------------------------------------- + # Axis / input accessors + # --------------------------------------------------------------------------- + + @property + def saddle_scale(self): + """Returns the AxisDispatcher for the saddle (Z) axis.""" + return self.app.els.get_z_axis() + + @property + def cross_slide_scale(self): + """Returns the AxisDispatcher for the cross-slide (X) axis.""" + return self.app.els.get_x_axis() + + @property + def saddle_input(self): + """Returns the InputDispatcher (raw encoder) for the saddle axis.""" + axis = self.saddle_scale + return axis._primary_input() if axis is not None else None + + @property + def cross_slide_input(self): + """Returns the InputDispatcher (raw encoder) for the cross-slide axis.""" + axis = self.cross_slide_scale + return axis._primary_input() if axis is not None else None + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + def __init__(self, bar): + from rcp.app import MainApp + log.info("Initializing AssistedThreadingWizard") + self.bar = bar + self.app: MainApp = MainApp.get_running_app() + self.servo = self.app.servo + self.current_step = 0 + self._threading_started = False + self._threading_active_confirmed = False + self._current_callback = None + self._servo_watch_callback = None + self.manual_stop_length = None + self.manual_cutting_depth = None + self._last_saddle_encoder_value = None + self._start_position_preloaded = False + self._steps = [ + self._step_set_initial_position, # Step 1 + self._step_set_stop_position, # Step 2 + self._step_set_material_width_position, # Step 3 + self._step_set_final_cutting_depth_position, # Step 4 + self._step_engage_half_nut, # Step 5 + self._step_go_to_start, # Step 6 + self._step_cut_thread, # Step 7 + self._step_depth_reached # Step 8 + ] + + def start(self): + dev = self.app.board.device + dev['assistedThreadingData']['spindlePhaseTolerance'] = self.app.els.at_rotary_encoder_sync_tolerance + + spindle_axis = self.app.els.get_spindle_axis() + if spindle_axis is not None: + inp = spindle_axis._primary_input() + if inp is not None: + dev['assistedThreadingData']['spindleCountsPerRev'] = int(spindle_axis._steps_per_revolution()) + dev['assistedThreadingData']['spindleScaleIndex'] = inp.inputIndex + + self.goto_step(0) + + def stop(self): + # Reset wizard_area to default content + log.info("Wizard finished") + self._current_callback = None + self._threading_started = False + self._threading_active_confirmed = False + self.bar.label_text = "" + self.bar.display_value = "" + self.bar.action_button_enabled = True + self.bar.action_button_condition_fn = None + self.bar.is_running = False + self.bar.retract_button_visible = False + self._clear_bar_display() + self._reset_servo_watch_callback() + self._reset_encoder_stability_check() + + if self.app.board.connected: + self.app.board.device['assistedThreadingData']['threadReset'] = 1 + self._stop_servo() + + def goto_step(self, index): + self.current_step = index + if 0 <= index < len(self._steps): + self._steps[index]() + else: + self.stop() + + def goto_next_step(self, *args): + # call the callback; it may return False to tell us "do not auto-advance" + result = None + if self._current_callback: + result = self._current_callback(*args) + + # If callback returned exactly False => callback will handle advancement later + if result is False: + return + + if self.bar.is_running: # check to ensure still running and we didn't stop in the callback + self.goto_step(self.current_step + 1) + + # --------------------------------------------------------------------------- + # Instruction / UI setup + # --------------------------------------------------------------------------- + + def set_instruction(self, label_text, next_button_text, next_button_callback, value_button_fn=None, action_button_condition_fn=None, retract_button_visible=False, retract_button_condition_fn=None): + self.bar.label_text = label_text + self.bar.next_button_text = next_button_text + self._current_callback = next_button_callback + self.bar.bind_btn_value_on_release(value_button_fn) + self.bar.action_button_condition_fn = action_button_condition_fn + self.bar.retract_button_visible = retract_button_visible + self.bar.retract_button_condition_fn = retract_button_condition_fn + + # --------------------------------------------------------------------------- + # Retract control + # --------------------------------------------------------------------------- + + def start_retracting(self): + log.info("Retract button pressed") + self.bar.action_button_enabled = False # disable action button while retracting + + if not self.app.board.connected: + return + self.bar.bind_display_value_to_servo_position() # bind to servo position + servo_direction = 1 if self.servo.ratioNum * self.servo.ratioDen > 0 else -1 + self.servo.jogSpeed = - servo_direction * self.app.els.at_reversing_speed # set to reversing speed + self._apply_reversing_adjusting_acceleration() + self.servo.set_max_speed(self.app.els.at_reversing_speed) # ensure step rate supports jog speed + self.servo.servoEnable = 2 + + def stop_retracting(self): + log.info("Retract button released") + self.bar.action_button_enabled = True # re-enable action button + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_buttons_state() + + if not self.app.board.connected: + return + self.servo.jogSpeed = 0 + + self._servo_watch_callback = self._watch_retracting_stopped + self.app.board.bind(update_tick=self._servo_watch_callback) + + # --------------------------------------------------------------------------- + # Step definitions + # --------------------------------------------------------------------------- + + # Step 1 + def _step_set_initial_position(self): + self.set_instruction("Go to initial Z and press Set", "Set", self._capture_initial_position) + self.bar.bind_display_value_to_scale(self.saddle_scale) + + # Step 2 + def _step_set_stop_position(self): + self.bar.action_button_enabled = False # Disable until valid + self.set_instruction("Go to or input stop Z and press Set", "Set", self._capture_stop_position, self._open_stop_position_keypad, self._is_valid_stop_position) + self.bar.bind_display_value_to_scale(self.saddle_scale) + + # Step 3 + def _step_set_material_width_position(self): + self.set_instruction("Go to material width and press Set", "Set", self._capture_material_width_position) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + + # Step 4 + def _step_set_final_cutting_depth_position(self): + self._clear_bar_display() + + # Calculate thread depth and show immediately + calculated_depth = self._calculate_thread_depth() + self.manual_cutting_depth = None # Reset manual override + if calculated_depth is not None: + is_metric = self.app.formats.current_format == "MM" + self.bar.display_value = f"{calculated_depth:.3f}" if is_metric else f"{calculated_depth:.4f}" + else: + self.bar.display_value = "" + + self.set_instruction( + "Enter Final Cutting Depth (auto-calculated shown, tap to override)", + "Set", + self._capture_final_cutting_depth_position, + self._open_final_cutting_depth_position_keypad + ) + + # Step 5 + def _step_engage_half_nut(self): + self.set_instruction("Engage half nut and press Next", "Next", None) + self._clear_bar_display() + + # Step 6 + def _step_go_to_start(self): + self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid + self.servo.servoEnable = 1 # Ensure servo enabled + self.set_instruction("Confirm cross slide retracted and press Go to return to start position", "Go", self._go_to_start, None, self._is_cross_slide_retracted, True, self._is_cross_slide_retracted) + self.bar.bind_display_value_to_scale(self.cross_slide_scale) + self.bar.update_buttons_state() + + # Step 7 + def _step_cut_thread(self): + self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid + self.set_instruction("Go to cutting depth and press Cut to start threading operation", "Cut", self._start_threading_operation, None, None, True) + self._bind_threading_progress_display() # Bind to progress display + self.bar.update_buttons_state() + + # Step 8 + def _step_depth_reached(self): + self.bar.action_button_enabled = False # Disable until valid + self.bar.retract_button_enabled = False # Disable until valid + self.set_instruction("Final depth reached. Cut more? Press Stop to quit.", "Cut", self._start_threading_operation, None, None, True) + self._bind_threading_progress_display() # Bind to progress display + self.bar.update_buttons_state() + + # --------------------------------------------------------------------------- + # Step callbacks + # --------------------------------------------------------------------------- + + # Step 1 + def _capture_initial_position(self, *args): + self.bar.start_position = self.saddle_input.encoderCurrent + self._isStartPositionMetricMode = self.app.formats.current_format == "MM" + self._startScaledPosition = self.saddle_scale.scaledPosition + log.info(f"Initial position set to: {self.bar.start_position}") + return True # advance to next step + + # Step 2 + def _capture_stop_position(self, *args): + self.bar.stop_position = self._get_stop_position_units() + self.manual_stop_length = None # reset for next run + log.info(f"Stop position set - (start={self.bar.start_position}, stop={self.bar.stop_position})") + return True # advance to next step + + # Step 3 + def _capture_material_width_position(self, *args): + self.bar.material_width = self.cross_slide_input.encoderCurrent + self.bar.last_cutting_depth = self.bar.material_width # Initialize last_cutting_depth to material_width + self._isMaterialWidthPositionMetricMode = self.app.formats.current_format == "MM" + self._materialWidthScaledPosition = self.cross_slide_scale.scaledPosition + log.info(f"Material width set to: {self.bar.material_width}") + return True # advance to next step + + # Step 4 + def _capture_final_cutting_depth_position(self, *args): + # Use manual override if set, otherwise use calculated depth + is_metric = self.app.formats.current_format == "MM" + depth = self.manual_cutting_depth if self.manual_cutting_depth is not None else self._calculate_thread_depth() + encoder_cutting_depth = self._convert_distance_units_to_encoder(self.cross_slide_scale, depth, is_metric) + + self.bar.cutting_depth = self.cross_slide_input.encoderCurrent - (encoder_cutting_depth * self._get_cross_slide_scale_effective_dir()) + + log.info(f"Cutting depth set: {depth} (manual_override={self.manual_cutting_depth is not None})") + self.bar.display_value = f"{depth:.3f}" if is_metric else f"{depth:.4f}" + return True # advance to next step + + # Step 7 + def _start_threading_operation(self, *args): + if not self.app.board.connected: + self.stop() + return False + + if not self._start_position_preloaded: + log.warning("Threading requested without start preload") + self.goto_step(5) + return False + + if not self._check_valid_start_position(): + return False + + if not self._check_spindle_turning_forward(): + return False + + if not self._check_spindle_speed_for_pitch(): + return False + + log.info("Starting threaded cut to stop position: %s", self.bar.stop_position) + self.bar.last_cutting_depth = self.cross_slide_input.encoderCurrent + self.bar.action_button_enabled = False + self.bar.retract_button_visible = False + + compound_z_offset = self._get_compound_z_offset_encoder() + + if compound_z_offset != 0: + # Compound infeed mode: physically shift the saddle by ΔZ before latching + target = self.bar.start_position + self._get_saddle_scale_effective_dir() * compound_z_offset + log.info(f"Compound infeed: shifting saddle by {compound_z_offset} encoder counts to {target}") + self._apply_reversing_adjusting_acceleration() + self._command_move_to_encoder(target, speed=self.app.els.at_preload_adjust_speed) + self._servo_watch_callback = self._watch_compound_z_move_done + self.app.board.bind(update_tick=self._servo_watch_callback) + else: + self._prepare_and_send_thread_latch() + + return False + + def _watch_compound_z_move_done(self, *_): + if not self._motion_complete(): + return + self._reset_servo_watch_callback() + log.info("Compound Z move complete, switching to threading acceleration and latching spindle") + self._prepare_and_send_thread_latch() + + def _prepare_and_send_thread_latch(self): + """Apply threading parameters, bind UI to servo, and send the latch command.""" + self._apply_threading_acceleration() + self._apply_threading_max_speed() + self.bar.bind_display_value_to_servo_position() + self._send_thread_latch() + + def _send_thread_latch(self): + """Write threading registers to firmware. threadRemainingSteps is calculated + fresh from the current saddle position every pass.""" + if not self._check_saddle_not_past_stop(): + return + + threading_delta_steps = self._get_threading_servo_delta_steps() + dev = self.app.board.device + + if not self._threading_started: + self._threading_started = True + self._threading_active_confirmed = False + dev['assistedThreadingData']['threadRemainingSteps'] = threading_delta_steps + dev['assistedThreadingData']['threadRequest'] = 1 + else: + self._threading_active_confirmed = False + dev['assistedThreadingData']['threadRemainingSteps'] = threading_delta_steps + dev['assistedThreadingData']['threadEnabled'] = 1 + + log.info( + f"Threading latch sent: threadRemainingSteps={threading_delta_steps}, " + f"servoCurrent={self.app.board.fast_data_values['servoCurrent']}, " + f"saddle_current={self.saddle_input.encoderCurrent}, stop={self.bar.stop_position}" + ) + + self._servo_watch_callback = lambda *a: self._check_servo_threading_done(5, *a) + self.app.board.bind(update_tick=self._servo_watch_callback) + + def _check_servo_threading_done(self, next_step: int, *args): + dev = self.app.board.device + dev['assistedThreadingData'].refresh() + threadPhaseActive = dev['assistedThreadingData']['threadPhaseActive'] + threadEnabled = dev['assistedThreadingData']['threadEnabled'] + + if log.isEnabledFor(logging.DEBUG): + spindleScaleIndex = dev['assistedThreadingData']['spindleScaleIndex'] + log.debug( + f"Checking servo done: " + f"spindleScaleIndex={spindleScaleIndex}, " + f"spindleCountsPerRev={dev['assistedThreadingData']['spindleCountsPerRev']}, " + f"spindlePhaseTolerance={dev['assistedThreadingData']['spindlePhaseTolerance']}, " + f"threadRequest={dev['assistedThreadingData']['threadRequest']}, " + f"threadReset={dev['assistedThreadingData']['threadReset']}, " + f"threadPhaseActive={threadPhaseActive}, " + f"threadEnabled={threadEnabled}, " + f"syncEnable={dev['scales'][spindleScaleIndex]['syncEnable']}, " + f"threadPhaseRef={dev['assistedThreadingData']['threadPhaseRef']}, " + f"currentThreadPhase={dev['assistedThreadingData']['currentThreadPhase']}, " + f"spindleEncoderPosition={dev['scales'][spindleScaleIndex]['position']}, " + f"threadRemainingSteps={dev['assistedThreadingData']['threadRemainingSteps']}, " + f"threadStartSteps={dev['assistedThreadingData']['threadStartSteps']}, " + f"desiredSteps={dev['servo']['desiredSteps']}, " + f"currentSteps={dev['servo']['currentSteps']}, " + ) + + if threadEnabled == 1 or threadPhaseActive == 1: + self._threading_active_confirmed = True + + if self._threading_active_confirmed and threadEnabled == 0 and threadPhaseActive == 0: + log.info("Servo reached desired position") + + # Stop watching + self._reset_servo_watch_callback() + + self.goto_step(next_step) + + # --------------------------------------------------------------------------- + # Manual input keypads + # --------------------------------------------------------------------------- + + def _open_stop_position_keypad(self, *args): + from rcp.components.popups.keypad import Keypad + + is_metric = self.app.formats.current_format == "MM" + + keypad = Keypad(title="Enter Stop Length (" + ("mm" if is_metric else "in") + ")") + keypad.integer = False + + def on_done(value): + try: + self.manual_stop_length = float(value) + log.info(f"Manual stop length entered: {self.manual_stop_length}") + # Display this override until user moves scale again + self.bar.display_value = f"{self.manual_stop_length:.3f}" if is_metric else f"{self.manual_stop_length:.4f}" + except ValueError: + log.warning(f"Invalid stop length input: {value}") + finally: + self.bar.update_buttons_state() + + keypad.show_with_callback(callback_fn=on_done, + current_value=self.manual_stop_length or 0.0) + + def _open_final_cutting_depth_position_keypad(self, *args): + from rcp.components.popups.keypad import Keypad + is_metric = self.app.formats.current_format == "MM" + # Always use calculated depth as default + calculated_depth = self._calculate_thread_depth() + default_value = calculated_depth if calculated_depth is not None else 0.0 + depth_unit = "mm" if is_metric else "in" + keypad = Keypad(title=f"Enter Final Cutting Depth ({depth_unit})") + keypad.integer = False + + def on_done(value): + try: + self.manual_cutting_depth = abs(float(value)) + log.info(f"Manual cutting depth entered: {self.manual_cutting_depth}") + self.bar.display_value = f"{self.manual_cutting_depth:.3f}" if is_metric else f"{self.manual_cutting_depth:.4f}" + self.bar.action_button_enabled = True + except ValueError: + log.warning(f"Invalid cutting depth input: {value}") + self.bar.action_button_enabled = False + + log.info(f"Opening cutting depth keypad with calculated default: {default_value:.4f}") + keypad.show_with_callback(callback_fn=on_done, + current_value=self.manual_cutting_depth if self.manual_cutting_depth is not None else default_value) + + # --------------------------------------------------------------------------- + # Servo helpers + # --------------------------------------------------------------------------- + + def _stop_servo(self): + if not self.app.board.connected: + return + self.servo.set_max_speed(self.servo.maxSpeed) # restore speed + self.servo.servoEnable = 0 # disable + self._apply_original_servo_acceleration() # restore original acceleration if it was changed + + def _reset_servo_watch_callback(self): + if self._servo_watch_callback: + self.app.board.unbind(update_tick=self._servo_watch_callback) + self._servo_watch_callback = None + + def _clear_bar_display(self): + self.bar.unbind_all_display_value() + self.bar.display_value = "" + + def _apply_original_servo_acceleration(self): + self.app.board.device['servo']['acceleration'] = self.servo.acceleration + + def _apply_reversing_adjusting_acceleration(self): + rate = self.app.els.at_reversing_adjusting_acceleration + if rate and rate > 0: + self.app.board.device['servo']['acceleration'] = rate + else: + self._apply_original_servo_acceleration() + + def _apply_threading_acceleration(self): + rate = self.app.els.at_threading_acceleration + if rate and rate > 0: + self.app.board.device['servo']['acceleration'] = rate + else: + self._apply_original_servo_acceleration() + + def _apply_threading_max_speed(self): + target_speed = self.app.els.at_threading_max_speed + if target_speed and target_speed > 0: + self.servo.set_max_speed(target_speed) + else: + self.servo.set_max_speed(self.servo.maxSpeed) + + # --------------------------------------------------------------------------- + # Threading progress display + # --------------------------------------------------------------------------- + + def _bind_threading_progress_display(self): + """ + Bind display to show threading progress: "Last: | Rem: " + where: + - Last = incremental cut since last_cutting_depth + - Rem = remaining distance until final thread depth + """ + self.bar.unbind_all_display_value() + self._progress_display_scale = self.cross_slide_input + + def on_cross_slide_update(instance, value): + try: + is_metric = self.app.formats.current_format == "MM" + current_encoder = self.cross_slide_input.encoderCurrent + last_cutting_depth_encoder = self.bar.last_cutting_depth + factor = float(self.app.formats.factor) + + scale_ratio = abs(Fraction(self.cross_slide_input.ratioNum, self.cross_slide_input.ratioDen) * factor) + + # Calculate incremental cut depth in encoder units + incremental_cut_encoder = last_cutting_depth_encoder - current_encoder if self.bar.inner_thread else current_encoder - last_cutting_depth_encoder + + incremental_cut_display = incremental_cut_encoder * scale_ratio + # Calculate remaining depth + final_depth_encoder = current_encoder - self.bar.cutting_depth if self.bar.inner_thread else self.bar.cutting_depth - current_encoder + remaining_display = final_depth_encoder * scale_ratio + + if self.bar.compound_infeed_mode: + # Show compound Z offset in saddle display units + z_enc = self._get_compound_z_offset_encoder() + saddle_inp = self.saddle_input + saddle_factor = float(self.app.formats.MM_FRACTION if is_metric else self.app.formats.INCHES_FRACTION) + saddle_scale_factor = abs(float(saddle_inp.ratioDen) / float(saddle_inp.ratioNum)) if saddle_inp.ratioNum != 0 else 1.0 + z_display = z_enc * saddle_factor / saddle_scale_factor + if is_metric: + self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f} | Z+{z_display:.3f}" + else: + self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f} | Z+{z_display:.4f}" + else: + if is_metric: + self.bar.display_value = f"Last: {incremental_cut_display:.3f} | Rem: {remaining_display:.3f}" + else: + self.bar.display_value = f"Last: {incremental_cut_display:.4f} | Rem: {remaining_display:.4f}" + log.debug(f"Threading progress: incremental_cut={incremental_cut_display:.4f}, remaining={remaining_display:.4f}") + except Exception as e: + log.error(f"Error updating threading progress display: {e}") + + self._on_threading_progress_update = on_cross_slide_update + self.cross_slide_input.bind(encoderCurrent=on_cross_slide_update) + on_cross_slide_update(self.cross_slide_input, self.cross_slide_input.encoderCurrent) diff --git a/rcp/components/home/at_mode_layout.py b/rcp/components/home/at_mode_layout.py new file mode 100644 index 0000000..b2e8411 --- /dev/null +++ b/rcp/components/home/at_mode_layout.py @@ -0,0 +1,62 @@ +from kivy.uix.widget import Widget + +from rcp.components.home.assisted_threading.bar import AssistedThreadingBar +from rcp.components.home.coordbar import CoordBar +from rcp.components.home.dro_coordbar import DroCoordBar +from rcp.components.home.mode_layout import ModeLayout + + +class AtModeLayout(ModeLayout): + """AT mode: spindle axis uses CoordBar (Num/Den visible), all others use DroCoordBar + AssistedThreadingBar.""" + + def __init__(self, at_bar: AssistedThreadingBar, **kwargs): + super().__init__(**kwargs) + self.at_bar = at_bar + self.spacer = Widget() + self.build_axis_bars() + self.add_widget(self.spacer) + self.add_widget(self.at_bar) + + # Rebuild when ELS axis assignments change (e.g. saved settings load) + self.app.els.bind( + spindle_axis_index=lambda *_: self.rebuild_axes(), + z_axis_index=lambda *_: self.rebuild_axes(), + x_axis_index=lambda *_: self.rebuild_axes(), + ) + + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + + def _update_row_heights(self, *_): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + available = self.height - self.at_bar.height + row_height = min(available / num_rows, self.app.formats.max_row_height) + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + # spacer absorbs remaining space + + def build_axis_bars(self): + seen = set() + for axis in [ + self.app.els.get_z_axis(), + self.app.els.get_x_axis(), + self.app.els.get_spindle_axis(), + ]: + if axis is None or id(axis) in seen: + continue + seen.add(id(axis)) + cb = CoordBar(axis=axis) if axis.spindleMode else DroCoordBar(axis=axis) + self.axis_bars.append(cb) + self.add_widget(cb) + + def rebuild_axes(self): + self.remove_widget(self.spacer) + self.remove_widget(self.at_bar) + super().rebuild_axes() + self.add_widget(self.spacer) + self.add_widget(self.at_bar) + self._update_row_heights() diff --git a/rcp/components/home/coordbar.py b/rcp/components/home/coordbar.py index 0f7b071..7fb5378 100644 --- a/rcp/components/home/coordbar.py +++ b/rcp/components/home/coordbar.py @@ -25,6 +25,8 @@ def toggle_sync(self): if self.axis is not None: from rcp.app import MainApp app = MainApp.get_running_app() + if app.current_mode == 5: + return self.axis.toggle_sync(all_axes=list(app.axes)) def on_zero_press(self): diff --git a/rcp/components/home/dro_mode_layout.py b/rcp/components/home/dro_mode_layout.py index 5255994..aecdf31 100644 --- a/rcp/components/home/dro_mode_layout.py +++ b/rcp/components/home/dro_mode_layout.py @@ -1,13 +1,34 @@ from rcp.components.home.dro_coordbar import DroCoordBar from rcp.components.home.mode_layout import ModeLayout +from kivy.uix.widget import Widget class DroModeLayout(ModeLayout): - """DRO mode: simplified DroCoordBars filling all space, no bottom bar.""" + """DRO mode: simplified DroCoordBars, no bottom bar.""" def __init__(self, **kwargs): super().__init__(**kwargs) + self.build_axis_bars() + self.spacer = Widget() + self.add_widget(self.spacer) + + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + + def _update_row_heights(self, *args): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + + row_height = min(self.height / num_rows, self.app.formats.max_row_height) + + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + + # spacer absorbs remaining space (size_hint_y defaults to 1) def build_axis_bars(self): for axis_disp in self.app.axes: diff --git a/rcp/components/home/els_mode_layout.py b/rcp/components/home/els_mode_layout.py index a27504e..4eff8b1 100644 --- a/rcp/components/home/els_mode_layout.py +++ b/rcp/components/home/els_mode_layout.py @@ -15,10 +15,8 @@ ICON_CCW = "\uf0e2" # rotate-left ICON_STOP = "\uf04d" # stop -MAX_ROW_HEIGHT = 150 LONG_PRESS_THRESHOLD = 1.0 - class ElsSpindleInfo(BoxLayout): """Displays spindle speed with direction icon and absolute position with zero button.""" spindle_rpm = StringProperty("--") @@ -99,6 +97,7 @@ def __init__(self, els_bar: ElsBar, **kwargs): x_axis_index=lambda *a: self.rebuild_axes(), ) + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) self.bind(height=self._update_row_heights) self._update_row_heights() @@ -108,7 +107,7 @@ def _update_row_heights(self, *args): return available = self.height - self.els_bar.height - row_height = min(available / num_rows, MAX_ROW_HEIGHT) + row_height = min(available / num_rows, self.app.formats.max_row_height) self.spindle_info.size_hint_y = None self.spindle_info.height = row_height diff --git a/rcp/components/home/elsbar.py b/rcp/components/home/elsbar.py index 39bb5f7..966fec2 100644 --- a/rcp/components/home/elsbar.py +++ b/rcp/components/home/elsbar.py @@ -41,7 +41,13 @@ def __init__(self, **kwargs): self.mode_name = next(iter(feeds.table.keys())) self.current_feeds_table = feeds.table[self.mode_name] self.update_feeds_ratio(self, None) + + self.app.bind(current_mode=self.on_mode_change) self.bind(current_feeds_index=self.update_feeds_ratio) + + def on_mode_change(self, instance, mode): + if mode == 2: + self.update_feeds_ratio(None, None) def update_current_position(self): Factory.Keypad().show_with_callback(self.app.servo.set_current_position, self.app.servo.scaledPosition) @@ -53,6 +59,9 @@ def set_feed_ratio(self, table_name, index): self.current_feeds_index = index def update_feeds_ratio(self, instance, value): + if self.app.current_mode != 2: + return # only sync in ELS mode + ratio = self.current_feeds_table[self.current_feeds_index].ratio spindle_axis = self.app.board.get_spindle_axis() if spindle_axis is not None: diff --git a/rcp/components/home/home_toolbar.py b/rcp/components/home/home_toolbar.py index a03bc93..cbed148 100644 --- a/rcp/components/home/home_toolbar.py +++ b/rcp/components/home/home_toolbar.py @@ -48,6 +48,8 @@ def update_current_mode(self, instance, value): self.current_mode_desc = "JOG" if self.app.current_mode == 4: self.current_mode_desc = "DRO" + if self.app.current_mode == 5: + self.current_mode_desc = "AT" def popup_mode(self, *_): ModePopup().show_with_callback(self.app.set_mode, self.app.current_mode) diff --git a/rcp/components/home/index_mode_layout.py b/rcp/components/home/index_mode_layout.py index 6d37fb6..599135f 100644 --- a/rcp/components/home/index_mode_layout.py +++ b/rcp/components/home/index_mode_layout.py @@ -1,6 +1,7 @@ from rcp.components.home.coordbar import CoordBar from rcp.components.home.mode_layout import ModeLayout from rcp.components.home.servobar import ServoBar +from kivy.uix.widget import Widget class IndexModeLayout(ModeLayout): @@ -9,9 +10,16 @@ class IndexModeLayout(ModeLayout): def __init__(self, **kwargs): super().__init__(**kwargs) self.servo_bar = ServoBar() + self.spacer = Widget() + self.build_axis_bars() + self.add_widget(self.spacer) self.add_widget(self.servo_bar) + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + def build_axis_bars(self): for axis_disp in self.app.axes: cb = CoordBar(axis=axis_disp) @@ -22,3 +30,17 @@ def rebuild_axes(self): self.remove_widget(self.servo_bar) super().rebuild_axes() self.add_widget(self.servo_bar) + + def _update_row_heights(self, *args): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + + available = self.height - self.servo_bar.height + row_height = min(available / num_rows, self.app.formats.max_row_height) + + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + + # spacer absorbs remaining space (size_hint_y defaults to 1) diff --git a/rcp/components/home/jog_mode_layout.py b/rcp/components/home/jog_mode_layout.py index 389ba1f..2b3784d 100644 --- a/rcp/components/home/jog_mode_layout.py +++ b/rcp/components/home/jog_mode_layout.py @@ -1,6 +1,7 @@ from rcp.components.home.coordbar import CoordBar from rcp.components.home.jogbar import JogBar from rcp.components.home.mode_layout import ModeLayout +from kivy.uix.widget import Widget class JogModeLayout(ModeLayout): @@ -10,8 +11,14 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self.jog_bar = JogBar() self.build_axis_bars() + self.spacer = Widget() + self.add_widget(self.spacer) self.add_widget(self.jog_bar) + self.app.formats.bind(max_row_height=lambda *_: self._update_row_heights()) + self.bind(height=self._update_row_heights) + self._update_row_heights() + def build_axis_bars(self): for axis_disp in self.app.axes: cb = CoordBar(axis=axis_disp) @@ -22,3 +29,17 @@ def rebuild_axes(self): self.remove_widget(self.jog_bar) super().rebuild_axes() self.add_widget(self.jog_bar) + + def _update_row_heights(self, *args): + num_rows = len(self.axis_bars) + if num_rows == 0: + return + + available = self.height - self.jog_bar.height + row_height = min(available / num_rows, self.app.formats.max_row_height) + + for bar in self.axis_bars: + bar.size_hint_y = None + bar.height = row_height + + # spacer absorbs remaining space (size_hint_y defaults to 1) diff --git a/rcp/components/popups/mode_popup.py b/rcp/components/popups/mode_popup.py index 3bd969b..a5a57c5 100644 --- a/rcp/components/popups/mode_popup.py +++ b/rcp/components/popups/mode_popup.py @@ -26,6 +26,8 @@ def __init__(self, **kwargs): buttons.add_widget(KeypadButton(text="ELS", return_value=2, on_release=self.confirm)) buttons.add_widget(KeypadButton(text="JOG", return_value=3, on_release=self.confirm)) buttons.add_widget(KeypadButton(text="DRO", return_value=4, on_release=self.confirm)) + if self.app.servo.elsMode: + buttons.add_widget(KeypadButton(text="AT", return_value=5, on_release=self.confirm)) self.add_widget(buttons) self.callback_fn = None diff --git a/rcp/components/screens/els_setup_screen.kv b/rcp/components/screens/els_setup_screen.kv index f9291a4..464a8b8 100644 --- a/rcp/components/screens/els_setup_screen.kv +++ b/rcp/components/screens/els_setup_screen.kv @@ -35,3 +35,72 @@ name: "Cross Slide Axis (X)" help_file: "els_axis_roles.md" on_value: root.on_x_selected(self, self.value) + + TitleItem: + name: "Assisted Threading" + BooleanItem: + name: "Cross Slide Diameter Mode" + help_file: "at_cross_slide_diameter_mode.md" + value: app.els.at_cross_slide_diameter_mode + on_value: app.els.at_cross_slide_diameter_mode = self.value + + TitleItem: + name: "Assisted Threading: Speed" + NumberItem: + name: "Reversing Speed (Steps/s)" + help_file: "at_reversing_speed.md" + value: app.els.at_reversing_speed + on_value: root.set_at_reversing_speed(self.value) + NumberItem: + name: "Preload/Adjust Speed (Steps/s)" + help_file: "at_preload_adjust_speed.md" + value: app.els.at_preload_adjust_speed + on_value: root.set_at_preload_adjust_speed(self.value) + NumberItem: + name: "Threading Max Speed (Steps/s)" + help_file: "at_threading_max_speed.md" + value: app.els.at_threading_max_speed + on_value: root.set_at_threading_max_speed(self.value) + NumberItem: + name: "Reversing/Adjusting Acceleration (Steps/s^2)" + help_file: "at_reversing_adjusting_acceleration.md" + value: app.els.at_reversing_adjusting_acceleration + on_value: root.set_at_reversing_adjusting_acceleration(self.value) + NumberItem: + name: "Threading Acceleration (Steps/s^2)" + help_file: "at_threading_acceleration.md" + value: app.els.at_threading_acceleration + on_value: root.set_at_threading_acceleration(self.value) + + TitleItem: + name: "Assisted Threading: Tolerances" + NumberItem: + name: "Rotary Encoder sync tolerance (Steps)" + help_file: "at_rotary_encoder_sync_tolerance.md" + value: app.els.at_rotary_encoder_sync_tolerance + on_value: app.els.at_rotary_encoder_sync_tolerance = self.value + NumberItem: + name: "Saddle Encoder stability tolerance (Steps)" + help_file: "at_saddle_encoder_stability_tolerance.md" + value: app.els.at_saddle_encoder_stability_tolerance + on_value: app.els.at_saddle_encoder_stability_tolerance = int(self.value) + NumberItem: + name: "Saddle Encoder stability samples" + help_file: "at_saddle_encoder_stability_samples.md" + value: app.els.at_saddle_encoder_stability_samples + on_value: app.els.at_saddle_encoder_stability_samples = int(self.value) + BooleanItem: + name: "Metric Distances" + help_file: "at_metric_distances.md" + value: app.els.at_metric_distances + on_value: app.els.at_metric_distances = self.value + NumberItem: + name: "Saddle backlash distance (MM)" if app.els.at_metric_distances else "Saddle backlash distance (IN)" + help_file: "at_saddle_backlash_distance.md" + value: app.els.at_saddle_backlash_distance + on_value: app.els.at_saddle_backlash_distance = self.value + NumberItem: + name: "Saddle backlash cushion (MM)" if app.els.at_metric_distances else "Saddle backlash cushion (IN)" + help_file: "at_backlash_cushion.md" + value: app.els.at_backlash_cushion + on_value: app.els.at_backlash_cushion = self.value diff --git a/rcp/components/screens/els_setup_screen.py b/rcp/components/screens/els_setup_screen.py index 4d1f754..adfc538 100644 --- a/rcp/components/screens/els_setup_screen.py +++ b/rcp/components/screens/els_setup_screen.py @@ -39,6 +39,42 @@ def on_z_selected(self, instance, value): def on_x_selected(self, instance, value): self.els.x_axis_index = self._name_to_index(value) + def set_at_reversing_speed(self, val): + try: + self.app.els.at_reversing_speed = min(int(val), self.app.servo.maxSpeed) + except ValueError: + pass + + def set_at_preload_adjust_speed(self, val): + try: + self.app.els.at_preload_adjust_speed = min(int(val), self.app.servo.maxSpeed) + except ValueError: + pass + + def set_at_threading_max_speed(self, val): + try: + speed = min(int(val), self.app.servo.maxSpeed) + if speed > 0: + self.app.els.at_threading_max_speed = speed + except (ValueError, TypeError): + pass + + def set_at_reversing_adjusting_acceleration(self, val): + try: + acc = int(val) + if acc > 0: + self.app.els.at_reversing_adjusting_acceleration = acc + except ValueError: + pass + + def set_at_threading_acceleration(self, val): + try: + acc = int(val) + if acc > 0: + self.app.els.at_threading_acceleration = acc + except ValueError: + pass + def _name_to_index(self, name: str) -> int: if name == NONE_LABEL: return -1 diff --git a/rcp/components/screens/formats_screen.kv b/rcp/components/screens/formats_screen.kv index 9d2b6a5..1bf1057 100644 --- a/rcp/components/screens/formats_screen.kv +++ b/rcp/components/screens/formats_screen.kv @@ -107,6 +107,10 @@ value: root.formats.hide_mouse_cursor help_file: "hide_mouse_cursor.md" on_value: root.formats.hide_mouse_cursor = self.value + NumberItem: + name: "Max axis row height (px)" + value: root.formats.max_row_height + on_value: root.formats.max_row_height = self.value TitleItem: name: "Sound Settings" diff --git a/rcp/components/screens/home_screen.py b/rcp/components/screens/home_screen.py index 0020145..d2a537d 100644 --- a/rcp/components/screens/home_screen.py +++ b/rcp/components/screens/home_screen.py @@ -5,6 +5,8 @@ from kivy.clock import Clock from kivy.uix.screenmanager import Screen +from rcp.components.home.assisted_threading.bar import AssistedThreadingBar +from rcp.components.home.at_mode_layout import AtModeLayout from rcp.components.home.dro_mode_layout import DroModeLayout from rcp.components.home.els_mode_layout import ElsModeLayout from rcp.components.home.elsbar import ElsBar @@ -33,12 +35,16 @@ def __init__(self, **kv): # Create shared ElsBar (has SavingDispatcher state) self.els_bar = ElsBar(id_override="0") + # Create shared AssistedThreadingBar (has SavingDispatcher state) + self.at_bar = AssistedThreadingBar(id_override="0") + # Create all mode layouts once self.mode_layouts = { 1: IndexModeLayout(), 2: ElsModeLayout(els_bar=self.els_bar), 3: JogModeLayout(), 4: DroModeLayout(), + 5: AtModeLayout(at_bar=self.at_bar), } # Configure initial mode, disable Indexing if servo is in ELS mode @@ -74,6 +80,10 @@ def change_mode_speed_check(self, instance): jog_layout.jog_bar.enable_jog = False self.app.servo.servoEnable = 0 + # Stop AT wizard if leaving AT mode + if self.current_layout is self.mode_layouts[5]: + self.at_bar.stop_wizard() + # Swap the entire mode layout if self.current_layout is not None: self.bars_container.remove_widget(self.current_layout) diff --git a/rcp/components/widgets/custom_popup.kv b/rcp/components/widgets/custom_popup.kv new file mode 100644 index 0000000..7bfc6a1 --- /dev/null +++ b/rcp/components/widgets/custom_popup.kv @@ -0,0 +1,21 @@ +: + orientation: "vertical" + padding: dp(20) + spacing: dp(10) + + Label: + id: lbl_message + text: root.message + halign: "center" + valign: "middle" + text_size: self.size + color: [1, 1, 1, 1] + font_size: "18sp" + + Button: + id: btn_close + text: root.button_text + size_hint_y: None + height: dp(48) + background_color: [0.2, 0.2, 0.2, 1] + on_release: root.on_button_press() \ No newline at end of file diff --git a/rcp/components/widgets/custom_popup.py b/rcp/components/widgets/custom_popup.py new file mode 100644 index 0000000..88b03bb --- /dev/null +++ b/rcp/components/widgets/custom_popup.py @@ -0,0 +1,40 @@ +import os +from kivy.logger import Logger +from kivy.properties import StringProperty, ObjectProperty +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.popup import Popup +from kivy.lang import Builder + +log = Logger.getChild(__name__) +kv_file = os.path.join(os.path.dirname(__file__), __file__.replace(".py", ".kv")) +if os.path.exists(kv_file): + log.info(f"Loading KV file {kv_file}") + Builder.load_file(kv_file) + + +class CustomPopup(BoxLayout): + title = StringProperty("") + message = StringProperty("") + button_text = StringProperty("OK") + on_dismiss_callback = ObjectProperty(None, allownone=True) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._popup = Popup( + title=self.title, + content=self, + size_hint=(0.6, 0.5), + auto_dismiss=False, + ) + + def open(self): + self._popup.title = self.title + self._popup.open() + + def dismiss(self): + self._popup.dismiss() + + def on_button_press(self): + if self.on_dismiss_callback: + self.on_dismiss_callback() + self.dismiss() \ No newline at end of file diff --git a/rcp/components/widgets/dropdown_item.py b/rcp/components/widgets/dropdown_item.py index 06deb93..4e855ef 100644 --- a/rcp/components/widgets/dropdown_item.py +++ b/rcp/components/widgets/dropdown_item.py @@ -1,6 +1,6 @@ from kivy.graphics import Color, Rectangle from kivy.logger import Logger -from kivy.properties import StringProperty, ListProperty, ObjectProperty +from kivy.properties import StringProperty, ListProperty, ObjectProperty, NumericProperty from rcp.components.popups.help_popup import HelpPopup # noqa: F401 from kivy.uix.boxlayout import BoxLayout from kivy.uix.dropdown import DropDown @@ -15,6 +15,7 @@ class DropDownItem(BoxLayout): + selected_index = NumericProperty(-1) name = StringProperty("") value = StringProperty(False) options = ListProperty([]) @@ -46,18 +47,26 @@ def on_value(self, instance, value): self.main_button.text = value def on_options(self, instance, value): - # Clean any existing self.delete_all_dropdown_options() + self._options = [] from rcp.app import MainApp app = MainApp.get_running_app() font_size = app.formats.font_size if app else 24 - for item in self.options: + for index, item in enumerate(self.options): btn = Button( text=item, size_hint_y=None, height=60, font_size=font_size, background_color=DROPDOWN_OPTION_COLOR, ) - btn.bind(on_release=lambda btn: self.dropdown.select(btn.text)) + btn.bind( + on_release=lambda btn, i=index: self._select(i) + ) self.dropdown.add_widget(btn) self._options.append(btn) + + def _select(self, index): + self.selected_index = index + self.value = self.options[index] + self.dropdown.dismiss() + diff --git a/rcp/components/widgets/hold_button.py b/rcp/components/widgets/hold_button.py new file mode 100644 index 0000000..88dfd1c --- /dev/null +++ b/rcp/components/widgets/hold_button.py @@ -0,0 +1,30 @@ +import os + +from kivy.logger import Logger +from kivy.uix.button import Button + +log = Logger.getChild(__name__) + +#Hold button created to keep tracking of press state even if cursor leaves button area +class HoldButton(Button): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._is_pressed = False + + def on_touch_down(self, touch): + if self.collide_point(*touch.pos): + self._is_pressed = True + self.dispatch('on_press') + # Grab the touch to keep receiving its events even if cursor leaves + touch.grab(self) + return True + return super().on_touch_down(touch) + + def on_touch_up(self, touch): + if touch.grab_current is self: + # Ensure we get the release even if outside button + self._is_pressed = False + self.dispatch('on_release') + touch.ungrab(self) + return True + return super().on_touch_up(touch) diff --git a/rcp/components/widgets/string_item.kv b/rcp/components/widgets/string_item.kv index 7a3912f..afcaf68 100644 --- a/rcp/components/widgets/string_item.kv +++ b/rcp/components/widgets/string_item.kv @@ -24,4 +24,4 @@ valign: "center" halign: "center" disabled: root.disabled - on_text_validate: root.value = self.text \ No newline at end of file + on_text_validate: root.set_value_from_text(self.text) \ No newline at end of file diff --git a/rcp/components/widgets/string_item.py b/rcp/components/widgets/string_item.py index 29d2cdc..e7b3181 100644 --- a/rcp/components/widgets/string_item.py +++ b/rcp/components/widgets/string_item.py @@ -14,3 +14,9 @@ class StringItem(BoxLayout): value = StringProperty("") disabled = BooleanProperty(False) help_file = StringProperty("") + + def set_value_from_text(self, text: str): + try: + self.value = text.encode("raw_unicode_escape").decode("unicode_escape") + except (UnicodeDecodeError, UnicodeEncodeError): + self.value = text diff --git a/rcp/dispatchers/els.py b/rcp/dispatchers/els.py index f6deb57..8137028 100644 --- a/rcp/dispatchers/els.py +++ b/rcp/dispatchers/els.py @@ -1,5 +1,5 @@ from kivy.logger import Logger -from kivy.properties import NumericProperty +from kivy.properties import BooleanProperty, NumericProperty from rcp.dispatchers.saving_dispatcher import SavingDispatcher @@ -7,16 +7,35 @@ class ElsDispatcher(SavingDispatcher): - """Persists ELS axis role assignments: spindle, saddle (Z), and cross slide (X).""" + """Persists ELS axis role assignments and Assisted Threading machine settings.""" _save_class_name = "Els" _skip_save = ["x", "y", "width", "height", "size_hint_x", "size_hint_y", "pos", "size", "minimum_height", "minimum_width", "padding", "spacing"] + # ── ELS axis roles ──────────────────────────────────────────────── spindle_axis_index = NumericProperty(-1) z_axis_index = NumericProperty(-1) x_axis_index = NumericProperty(-1) + # ── Assisted Threading: thread geometry ─────────────────────────── + at_cross_slide_diameter_mode = BooleanProperty(False) + + # ── Assisted Threading: speed & acceleration ────────────────────── + at_reversing_speed = NumericProperty(500) + at_preload_adjust_speed = NumericProperty(500) + at_threading_max_speed = NumericProperty(2000) + at_reversing_adjusting_acceleration = NumericProperty(1000) + at_threading_acceleration = NumericProperty(1000) + + # ── Assisted Threading: tolerances & backlash ───────────────────── + at_rotary_encoder_sync_tolerance = NumericProperty(5) + at_saddle_encoder_stability_tolerance = NumericProperty(1) + at_saddle_encoder_stability_samples = NumericProperty(3) + at_metric_distances = BooleanProperty(True) + at_saddle_backlash_distance = NumericProperty(10) + at_backlash_cushion = NumericProperty(2) + def __init__(self, **kwargs): from rcp.app import MainApp self.app: MainApp = MainApp.get_running_app() diff --git a/rcp/dispatchers/formats.py b/rcp/dispatchers/formats.py index 5ffdb49..35a2652 100644 --- a/rcp/dispatchers/formats.py +++ b/rcp/dispatchers/formats.py @@ -19,6 +19,9 @@ class FormatsDispatcher(SavingDispatcher): 'color_on', 'color_off' ] + + MM_FRACTION = Fraction(1, 1) + INCHES_FRACTION = Fraction(10, 254) metric_position = StringProperty("{:+0.3f}") metric_speed = StringProperty("{:+0.3f}") @@ -35,7 +38,7 @@ class FormatsDispatcher(SavingDispatcher): current_format = StringProperty("MM") speed_format = StringProperty() position_format = StringProperty() - factor = ObjectProperty(Fraction(1, 1)) + factor = ObjectProperty(MM_FRACTION) display_color = ColorProperty("#ffcc35ff") accept_color = ColorProperty("#32ff32ff") @@ -57,6 +60,8 @@ class FormatsDispatcher(SavingDispatcher): show_speeds = BooleanProperty(True) show_wizard = BooleanProperty(True) + max_row_height = NumericProperty(150) + def __init__(self, **kv): super().__init__(**kv) self.angle_speed_format = self.angle_speed_format.replace("RPM", "").replace(" ", "") @@ -69,11 +74,11 @@ def update_format(self, *args, **kv): if self.current_format == "MM": self.speed_format = f"{self.metric_speed} {self.metric_speed_unit}" self.position_format = self.metric_position - self.factor = Fraction(1, 1) + self.factor = self.MM_FRACTION else: self.speed_format = f"{self.imperial_speed} {self.imperial_speed_unit}" self.position_format = self.imperial_position - self.factor = Fraction(10, 254) + self.factor = self.INCHES_FRACTION def toggle(self, *_): if self.current_format == "MM": diff --git a/rcp/dispatchers/input.py b/rcp/dispatchers/input.py index b5a911f..2a931bf 100644 --- a/rcp/dispatchers/input.py +++ b/rcp/dispatchers/input.py @@ -41,12 +41,14 @@ class InputDispatcher(SavingDispatcher): position = NumericProperty(0) scaled_value = NumericProperty(0) steps_per_second = NumericProperty(0) + encoderCurrent = NumericProperty(0) _skip_save = [ "position", "scaled_value", "steps_per_second", "_spindle_wrap_steps", + "encoderCurrent", ] def __init__(self, board, **kv): @@ -57,7 +59,6 @@ def __init__(self, board, **kv): # Encoder tracking state self.encoderPrevious = 0 - self.encoderCurrent = 0 # Bindings self.board.bind(update_tick=self._on_update_tick) diff --git a/rcp/dispatchers/servo.py b/rcp/dispatchers/servo.py index fd9a7b9..b5c0bfe 100644 --- a/rcp/dispatchers/servo.py +++ b/rcp/dispatchers/servo.py @@ -116,7 +116,7 @@ def on_connected(self, instance, value): self.encoderPrevious = self.board.fast_data_values['servoCurrent'] self.encoderCurrent = self.board.fast_data_values['servoCurrent'] self.servoEnable = self.board.fast_data_values['servoEnable'] - self.board.device['servo']['maxSpeed'] = self.maxSpeed + self.set_max_speed(self.maxSpeed) self.board.device['servo']['acceleration'] = self.acceleration if self.servoEnable == 0: @@ -192,6 +192,9 @@ def go_next(self): def go_previous(self): self.preferredDirection = -1 self.index = (self.index - 1) % self.divisions + + def set_max_speed(self, value): + self.board.device['servo']['maxSpeed'] = self.maxSpeed def on_index(self, instance, value): ratio = Fraction(self.ratioNum, self.ratioDen) @@ -233,7 +236,7 @@ def on_offset(self, instance, value): self.oldOffset = value def on_maxSpeed(self, instance, value): - self.board.device['servo']['maxSpeed'] = self.maxSpeed + self.set_max_speed(self.maxSpeed) def on_jogSpeed(self, instance, value): self.board.device['servo']['jogSpeed'] = self.jogSpeed diff --git a/rcp/feeds.py b/rcp/feeds.py index 3bac117..75ea6e5 100644 --- a/rcp/feeds.py +++ b/rcp/feeds.py @@ -9,11 +9,16 @@ class FeedConfiguration(BaseModel): ratio: Optional[Fraction] = None THREAD_MM = [ + FeedConfiguration(name="0.20", ratio=Fraction("0.2"), mode=1), + FeedConfiguration(name="0.25", ratio=Fraction("0.25"), mode=1), + FeedConfiguration(name="0.30", ratio=Fraction("0.3"), mode=1), FeedConfiguration(name="0.35", ratio=Fraction("0.35"), mode=1), FeedConfiguration(name="0.40", ratio=Fraction("0.4"), mode=1), FeedConfiguration(name="0.45", ratio=Fraction("0.45"), mode=1), FeedConfiguration(name="0.50", ratio=Fraction("0.5"), mode=1), + FeedConfiguration(name="0.60", ratio=Fraction("0.6"), mode=1), FeedConfiguration(name="0.70", ratio=Fraction("0.7"), mode=1), + FeedConfiguration(name="0.75", ratio=Fraction("0.75"), mode=1), FeedConfiguration(name="0.80", ratio=Fraction("0.8"), mode=1), FeedConfiguration(name="1.00", ratio=Fraction("1"), mode=1), FeedConfiguration(name="1.25", ratio=Fraction("1.25"), mode=1), @@ -24,6 +29,10 @@ class FeedConfiguration(BaseModel): FeedConfiguration(name="3.00", ratio=Fraction("3"), mode=1), FeedConfiguration(name="3.50", ratio=Fraction("3.5"), mode=1), FeedConfiguration(name="4.00", ratio=Fraction("4"), mode=1), + FeedConfiguration(name="4.50", ratio=Fraction("4.5"), mode=1), + FeedConfiguration(name="5.00", ratio=Fraction("5"), mode=1), + FeedConfiguration(name="5.50", ratio=Fraction("5.5"), mode=1), + FeedConfiguration(name="6.00", ratio=Fraction("6"), mode=1), ] THREAD_IN = [ diff --git a/rcp/help/at_backlash_cushion.rst b/rcp/help/at_backlash_cushion.rst new file mode 100644 index 0000000..c2d55fe --- /dev/null +++ b/rcp/help/at_backlash_cushion.rst @@ -0,0 +1,62 @@ +Saddle Backlash Cushion +======================= + +A small distance value that serves two safety roles in the threading +wizard. + +Role 1 — Minimum Thread Length +-------------------------------- + +The stop position you set in the wizard must be at least this distance +from the start position. If the stop is closer than the cushion, the +system rejects it. + +This prevents attempting to cut a thread so short that the preload +and go-to-start sequence cannot work reliably within the available +length. + +Role 2 — Start Position Validation +----------------------------------- + +Before each threading cut begins, the system checks that the saddle +is within this distance of the configured start position. If the +saddle has drifted further away (e.g. overshot during preload), the +cut is aborted and you are returned to the go-to-start step. + +This ensures every cut starts from a consistent position, which is +especially important for fine pitches where a small positional error +shifts the thread by a significant fraction of the pitch. + +How to Set +---------- + +Use a small fraction of your Saddle Backlash Distance value, and +keep it smaller than the shortest pitch you intend to cut: + +- Example: 2 mm backlash → 0.1 mm cushion +- Rule of thumb: cushion < backlash distance / 10 +- Also ensure: cushion < your minimum thread pitch + +Too Large +--------- + +- Short threads are unnecessarily rejected +- A larger tolerance is allowed at the start position — the saddle + may be further off the ideal start, which for fine pitches can cause + the thread to start at the wrong phase and damage the workpiece + +Too Small +--------- + +- Higher accuracy is required from the go-to-start sequence +- Depending on the machine, it may be difficult to consistently land + within a very tight tolerance — this causes frequent false aborts + before cuts + +Notes +----- + +- Set the Metric Distances toggle above to match the unit before + entering this value +- If you see repeated "not at valid start position" warnings, consider + slightly increasing the cushion or reducing the preload/adjust speed diff --git a/rcp/help/at_compound_infeed.rst b/rcp/help/at_compound_infeed.rst new file mode 100644 index 0000000..7ebe06e --- /dev/null +++ b/rcp/help/at_compound_infeed.rst @@ -0,0 +1,67 @@ +Compound Infeed +=============== + +Enables angled infeed for threading — each pass shifts the thread +start position slightly forward along the Z axis instead of plunging +the tool straight radially. + +How It Works +------------ + +In standard (radial) infeed, the tool plunges straight in and cuts +both flanks of the thread simultaneously on every pass. This is simple +but increases cutting force as depth increases. + +With compound infeed active, each new pass begins at a Z start +position offset slightly toward the thread stop. The tool therefore +cuts predominantly on the **leading flank**, reducing the chip load +per pass. This is similar to using a compound slide set at the thread +half-angle. + +Benefits +-------- + +- Reduced cutting force per pass +- Less tendency for chatter, especially on coarser pitches +- Better surface finish on the thread flanks +- Easier on tool inserts and HSS tools + +Compound Offset (0–5°) +---------------------- + +The offset angle in degrees, subtracted from the thread half-angle to +give the actual compound infeed angle: + + **compound angle = thread half-angle − offset** + +The half-angle depends on the selected Thread Type: + +============ =========== ========================== +Thread Type Half-angle Compound angle at 1° offset +============ =========== ========================== +ISO Metric 30° 29° +Unified 30° 29° +Whitworth 27.5° 26.5° +ACME 14.5° 13.5° +============ =========== ========================== + +**Typical setting:** 1° works well for most materials and pitches. +Increasing the offset angles the infeed more steeply toward the +leading flank. + +When to Use +----------- + +- Coarse pitches (> 1.5 mm / < 17 TPI): compound infeed gives the + greatest benefit +- Fine pitches (< 1 mm / > 25 TPI): the Z offset is very small; + compound infeed provides little benefit and can be left OFF + +Notes +----- + +- If a warning appears that the compound Z shift has consumed the + remaining thread length, reduce the cross-slide infeed depth or + increase the thread start clearance before pressing Cut +- ACME threads have a shallow half-angle (14.5°); keep the offset + small (1° or less) to avoid too steep an approach angle diff --git a/rcp/help/at_cross_slide_diameter_mode.rst b/rcp/help/at_cross_slide_diameter_mode.rst new file mode 100644 index 0000000..757850b --- /dev/null +++ b/rcp/help/at_cross_slide_diameter_mode.rst @@ -0,0 +1,43 @@ +Cross Slide Diameter Mode +========================= + +Tells the system how your cross-slide scale is physically configured — +whether it reads full diameter across the workpiece or radius (distance +from centre to tool tip). + +This is not a setting that changes scale behaviour. It is a declaration +of how you have set up and zeroed your DRO. + +Diameter Mode (ON) +------------------ + +The scale reads the full diameter of the material (both sides). For +example, zeroing with the tool touching the outside of a 20 mm shaft +gives a reading of 20 mm. + +When this mode is active, the auto-calculated thread cutting depth is +doubled — because reducing the diameter by the full thread depth +requires removing that depth from both sides of the workpiece. + +Radius Mode (OFF) +----------------- + +The scale reads radius — the distance from the centre of rotation to +the tool tip. This is the more common DRO convention for lathes. A +thread depth of 0.9 mm means the tool moves 0.9 mm inward. + +Which to Choose +--------------- + +Set this to match however your cross-slide DRO is zeroed: + +- **Touching outer surface → reading = diameter** → enable Diameter Mode +- **Touching outer surface → reading = radius (half diameter)** → leave OFF + +Notes +----- + +- This setting only affects the automatically calculated thread depth + shown in the wizard; it does not affect encoder or position readings +- If your finished threads are consistently too shallow or too deep by + a factor of 2, check that this setting matches your scale configuration diff --git a/rcp/help/at_inner_thread.rst b/rcp/help/at_inner_thread.rst new file mode 100644 index 0000000..f673a1c --- /dev/null +++ b/rcp/help/at_inner_thread.rst @@ -0,0 +1,44 @@ +Inner Thread +============ + +When enabled, the threading wizard drives the cross slide outward +for each cutting pass instead of inward, for cutting an internal +(female) thread inside a bore. + +External vs Internal +-------------------- + +**OFF (default) — External thread:** +The cross slide advances inward (toward the workpiece centreline) to +increase cutting depth. Used for cutting threads on the outside of a +shaft or bar. + +**ON — Internal thread:** +The cross slide advances outward (away from the centreline) to +increase cutting depth into a pre-bored hole. Used for cutting threads +on the inside of a hole. + +Preparation +----------- + +Before using the threading wizard for an internal thread: + +1. Drill or bore the hole to the correct tapping diameter for the + chosen thread and pitch +2. Ensure the boring bar or threading insert clears the hole diameter + through the full thread length + +Thread Depth +------------ + +The auto-calculated thread depth applies in both modes. In inner +thread mode, this depth is taken outward from the bore wall. + +Notes +----- + +- Changing this setting reverses the cross-slide direction check in + the wizard — the "retracted" state and "at cutting depth" condition + are both interpreted relative to the bore wall, not the centreline +- This setting does not affect the saddle (Z) direction — that is + controlled by Left Hand Thread diff --git a/rcp/help/at_left_hand_thread.rst b/rcp/help/at_left_hand_thread.rst new file mode 100644 index 0000000..6df1663 --- /dev/null +++ b/rcp/help/at_left_hand_thread.rst @@ -0,0 +1,42 @@ +Left Hand Thread +================ + +When enabled, the saddle feed direction is reversed so the thread +tightens when rotated counter-clockwise (viewed from the end). + +Standard vs Left Hand +--------------------- + +**OFF (default) — Right hand thread:** +The saddle moves toward the headstock during the cut. The thread +tightens by turning clockwise. + +**ON — Left hand thread:** +The saddle moves away from the headstock during the cut. The thread +tightens by turning counter-clockwise. + +Common Uses +----------- + +- Left-side pedal crank threads on bicycles +- Left-hand adjusters and lock nuts (to prevent loosening under + rotation) +- Specialty jigs and fixtures +- Turnbuckle bodies (one end right, one end left) + +What Changes +------------ + +Enabling left hand thread reverses the ELS sync ratio sign on the +spindle axis, causing the servo to drive in the opposite direction +relative to spindle rotation. The displayed sync ratio reflects this. + +Notes +----- + +- The spindle must still be turning in the same direction as for a + right-hand thread — do not reverse the lathe spindle; the firmware + handles feed direction reversal +- The threading wizard start and stop positions use the same + convention; set your start position where the thread begins and + stop position where it ends, in the physical direction of travel diff --git a/rcp/help/at_metric_distances.rst b/rcp/help/at_metric_distances.rst new file mode 100644 index 0000000..2dbbfb1 --- /dev/null +++ b/rcp/help/at_metric_distances.rst @@ -0,0 +1,35 @@ +Metric Distances +================ + +Selects the unit for the backlash settings on this screen. + +When ON, the Saddle Backlash Distance and Saddle Backlash Cushion +values are entered and interpreted in millimetres. + +When OFF, both values are entered in inches. + +Scope +----- + +This setting applies **only** to the two backlash fields immediately +below it: + +- Saddle Backlash Distance +- Saddle Backlash Cushion + +It does not affect: + +- Thread pitch units (set by Metric Mode in the threading settings popup) +- DRO display units (set in the Formats screen) +- Any other measurements in the application + +Notes +----- + +- Change this before entering backlash values — existing values are + stored as-is and are not automatically converted between units +- For metric measurements, leave this ON and enter distances in mm +- For imperial measurements, turn this OFF and enter + distances in inches +- This is purely for preference and ease of use to the user; the system + will automatically convert the entered values to the correct encoder units diff --git a/rcp/help/at_metric_mode.rst b/rcp/help/at_metric_mode.rst new file mode 100644 index 0000000..7ab697d --- /dev/null +++ b/rcp/help/at_metric_mode.rst @@ -0,0 +1,40 @@ +Metric Mode +=========== + +Selects the unit system for thread pitch in the threading settings. + +When ON, pitches are in **millimetres** (metric threads — ISO, ACME). +When OFF, pitches are in **threads per inch** (TPI — imperial threads, +Unified, Whitworth, ACME). + +Effect on Other Settings +------------------------ + +Changing this setting rebuilds both the pitch dropdown and the +available thread type options, as some thread types are only +defined for metric or imperial pitch systems. + +This setting does **not** affect: + +- DRO display units (set in the Formats screen) +- Backlash distance units (set by Metric Distances in ELS Setup) +- Encoder or servo configuration + +Which to Choose +--------------- + +========== ========================================== +Mode Use when +========== ========================================== +ON (MM) Cutting ISO metric threads (M-series) +OFF (TPI) Cutting Unified (UNC/UNF) or Whitworth threads +========== ========================================== + +ACME threads are available in both modes — select the mode that +matches the pitch specification of your lead screw or part drawing. + +Notes +----- + +- Switching this resets the pitch dropdown to the first available + option; re-select your desired pitch after changing diff --git a/rcp/help/at_pitch.rst b/rcp/help/at_pitch.rst new file mode 100644 index 0000000..36b1005 --- /dev/null +++ b/rcp/help/at_pitch.rst @@ -0,0 +1,58 @@ +Thread Pitch +============ + +The pitch of the thread to be cut. + +In **Metric Mode** the pitch is in millimetres — the distance +between adjacent thread crests measured along the axis. + +In **Imperial Mode** the pitch is in TPI (threads per inch) — the +number of complete thread crests in one inch of travel. + +How It Is Used +-------------- + +The selected pitch determines: + +1. **ELS sync ratio:** the ratio of spindle rotation to saddle + movement, set automatically on the spindle axis +2. **Auto-calculated thread depth:** shown as the default cutting + depth in the wizard (can be overridden) +3. **Maximum spindle RPM check:** if the pitch and spindle speed + require more servo speed than the Threading Max Speed limit, the + system warns you before the cut + +Common Pitches +-------------- + +**Metric (MM)** + +===== ================== +Pitch Typical use +===== ================== +0.5 M3, M4 fine +0.75 M5, M6 coarse +1.0 M6 fine, M8 coarse +1.25 M8 fine, M10 +1.5 M10 fine, M12 +2.0 M14, M16 +===== ================== + +**Imperial (TPI)** + +==== ======================= +TPI Typical use +==== ======================= +40 ¼-40 UNF +32 #10-32 UNF +20 ¼-20 UNC +13 ½-13 UNC +8 ¾-8 UNC +4 1½-4 UNC (coarse) +==== ======================= + +Notes +----- + +- The full pitch list is defined in the application's feeds table +- Switch Metric Mode first to see pitches in the correct unit system diff --git a/rcp/help/at_preload_adjust_speed.rst b/rcp/help/at_preload_adjust_speed.rst new file mode 100644 index 0000000..4f77399 --- /dev/null +++ b/rcp/help/at_preload_adjust_speed.rst @@ -0,0 +1,50 @@ +Preload / Adjust Speed (Steps/s) +================================ + +The servo speed used when positioning the saddle back to the thread +start during the go-to-start sequence. + +How It Works +------------ + +After retracting past the start position, the system performs two +precision moves before the next cut: + +1. **Preload move:** the saddle advances in the cutting direction to + take up the drive backlash (1.25× the configured backlash distance) +2. **Adjust move:** the saddle makes a final fine move to land exactly + at the thread start position + +Both moves use this speed. Slow, controlled movement here ensures the +saddle lands accurately at the start and the drive is fully loaded. + +Guidelines +---------- + +- Keep this speed low — positional accuracy is the priority here, + not throughput +- Too fast risks overshooting the start position or inadequate + backlash preloading, which leads to a thread out of phase on the + next pass + +Typical Ranges +-------------- + +=================== ===================== +Servo Max Speed Typical Preload Speed +=================== ===================== +500 steps/s 50–75 steps/s +1000 steps/s 100–150 steps/s +2000 steps/s 200–300 steps/s +3000 steps/s 300–450 steps/s +=================== ===================== + +**Typical setting:** 10–15% of your servo max speed. + +Notes +----- + +- Value is clamped to the servo max speed +- If the saddle consistently overshoots the start position, reduce + this value +- See also: Saddle Backlash Distance, Reversing/Adjusting Acceleration diff --git a/rcp/help/at_reversing_adjusting_acceleration.rst b/rcp/help/at_reversing_adjusting_acceleration.rst new file mode 100644 index 0000000..e0a3b1f --- /dev/null +++ b/rcp/help/at_reversing_adjusting_acceleration.rst @@ -0,0 +1,46 @@ +Reversing / Adjusting Acceleration (Steps/s²) +============================================= + +How quickly the servo ramps up and slows down during retraction, +preload, and adjust moves — the non-cutting phases of the threading +cycle. + +How It Works +------------ + +This acceleration is applied to three moves: + +1. **Retract:** saddle moves backward after a threading pass +2. **Preload:** saddle advances to load drive backlash before returning + to start +3. **Adjust:** final fine move to the exact thread start position + +For these positioning moves, smooth and controlled motion is more +important than speed. A lower acceleration produces gentler +deceleration into the start position, reducing overshoot. + +Guidelines +---------- + +- Keep this value lower than the threading acceleration — the priority + here is accurate stopping, not fast ramp-up +- Too high: the saddle may overshoot the start position, requiring + the system to detect it is out of position and abort the cut +- Too low: very gradual starts and stops; the retract and preload + moves feel sluggish + +Typical Range +------------- + +**Typical setting:** 50–100% of your Reversing Speed value. + +For example, if Reversing Speed = 1000 steps/s, set this to +500–1000 steps/s². + +Notes +----- + +- This is a separate setting from Threading Acceleration, which + handles the actual cutting moves +- If cuts are frequently aborted with a "not at valid start position" + warning, try reducing this value to improve stopping accuracy diff --git a/rcp/help/at_reversing_speed.rst b/rcp/help/at_reversing_speed.rst new file mode 100644 index 0000000..fba1ddf --- /dev/null +++ b/rcp/help/at_reversing_speed.rst @@ -0,0 +1,49 @@ +Reversing Speed (Steps/s) +========================= + +The servo speed used when the retract button is held, and during the +retract phase of the automatic go-to-start sequence. + +How It Works +------------ + +**Retract button (manual):** while held, the saddle jogs away from the +workpiece at this speed. This is useful when you want to move the saddle +clear of the workpiece — for example to test thread fit you might want to move +the saddle out of the way further than the start position. Releasing the button stops the saddle. + +**Go-to-start (automatic):** when you press Go to start between passes, +the system automatically retracts past the start position at this speed +before slowing to the preload/adjust speed to return precisely to start. +You do not need to use the retract button for normal between-pass +operation — the go-to-start button handles the full sequence. + +Guidelines +---------- + +- This speed can be set close to your servo max speed — quick + retraction saves time on each pass, especially for longer threads +- There is no requirement for this to be slow; accuracy is not + critical during retraction +- Higher values reduce the time between passes + +Typical Ranges +-------------- + +=================== =================== +Servo Max Speed Typical Reversing Speed +=================== =================== +500 steps/s 250–500 steps/s +1000 steps/s 500–1000 steps/s +2000 steps/s 1000–2000 steps/s +3000 steps/s 1500–3000 steps/s +=================== =================== + +**Typical setting:** 50–100% of your servo max speed. + +Notes +----- + +- Value is clamped to the servo max speed — it cannot exceed it +- The Reversing/Adjusting Acceleration setting controls how quickly + the saddle ramps up and down to this speed diff --git a/rcp/help/at_rotary_encoder_sync_tolerance.rst b/rcp/help/at_rotary_encoder_sync_tolerance.rst new file mode 100644 index 0000000..6f3f66c --- /dev/null +++ b/rcp/help/at_rotary_encoder_sync_tolerance.rst @@ -0,0 +1,55 @@ +Rotary Encoder Sync Tolerance (Counts) +======================================= + +Defines the ±window (in spindle encoder counts) around the reference +spindle angle at which each threading pass re-engages. + +How It Works +------------ + +When you press Cut for the first time, the firmware records the current +spindle angle as the **phase reference** for this thread. For every +subsequent pass, the firmware waits until the spindle returns to within +±tolerance counts of that same reference angle before enabling the +servo threading move. + +This phase-locking ensures every pass starts at the same point in the +spindle rotation, so successive cuts follow the same helical path and +do not cross-thread. + +Units +----- + +The value is in **spindle encoder counts** — the same scale as your +spindle encoder's counts per revolution (spindleCountsPerRev). + +To convert to degrees: **tolerance_degrees = (tolerance / counts_per_rev) × 360** + +Example: 1000-count encoder, tolerance = 5 → ±1.8° window. + +Guidelines +---------- + +- **Smaller value:** tighter re-engagement angle — more consistent + thread helix, but may delay the trigger if the encoder has noise +- **Larger value:** wider window — triggers more readily but the start + angle may vary slightly between passes + +Typical Range +------------- + +=========== ============================================================== +Tolerance Effect +=========== ============================================================== +1–3 counts Very tight — use only if encoder signal is clean +5 counts Default — suitable for most setups +10–15 counts Wider window — useful if threading start is unreliable +=========== ============================================================== + +Notes +----- + +- If threading passes never start (the servo just waits), increase + this value to widen the trigger window +- If cut helix alignment is poor between passes, reduce this value +- Value must be a positive integer diff --git a/rcp/help/at_saddle_backlash_distance.rst b/rcp/help/at_saddle_backlash_distance.rst new file mode 100644 index 0000000..ac42866 --- /dev/null +++ b/rcp/help/at_saddle_backlash_distance.rst @@ -0,0 +1,50 @@ +Saddle Backlash Distance +======================== + +The total free play in the saddle (Z-axis) drive — the distance the +handwheel travels before the saddle actually begins to move when +direction is reversed. + +How It Is Used +-------------- + +This distance is used to calculate the retract and preload move +distances in the go-to-start sequence: + +- **Retract:** moves 1.5× this distance past the thread start +- **Preload:** advances 1.25× this distance back toward the start + (to fully load the drive in the cutting direction) +- **Adjust:** final fine move to land on the exact start position + +Setting this correctly ensures the drive is fully preloaded before +the threading cut begins, eliminating backlash-induced position error +on the first stroke of each pass. + +How to Measure +-------------- + +1. Engage the half nut on the lathe +2. Turn the handwheel in the cutting direction until it stops + (the lead screw is loaded in one direction) +3. Zero the DRO +4. Turn the handwheel in the opposite direction until it stops +5. Read the DRO value — this is your backlash distance +6. Repeat at several points along the saddle travel and average + the readings for accuracy + +Common Values +------------- + +Backlash varies widely between machines. Freshly adjusted or +re-fitted nuts have less backlash; worn machines may have +significantly more. + +Notes +----- + +- Set the Metric Distances toggle above to match the unit you intend + to use before entering this value +- **Too low:** drive is not fully preloaded → first cut of each pass + may be shallower than expected +- **Too high:** unnecessarily long retract and preload moves, slowing + down each pass diff --git a/rcp/help/at_saddle_encoder_stability_samples.rst b/rcp/help/at_saddle_encoder_stability_samples.rst new file mode 100644 index 0000000..c0d8491 --- /dev/null +++ b/rcp/help/at_saddle_encoder_stability_samples.rst @@ -0,0 +1,46 @@ +Saddle Encoder Stability Samples +================================= + +The number of consecutive stable encoder readings required before the +saddle is confirmed as fully stopped. + +How It Works +------------ + +After a servo move, the system polls the saddle encoder on each +control tick. A reading is considered "stable" if the change in +position since the last reading is within the Saddle Encoder Stability +Tolerance. This setting specifies how many stable readings in a row +must be observed before the wizard advances. + +This guards against brief pauses in motion (such as when a fast- +moving saddle momentarily slows through the target) being mistakenly +treated as a full stop. + +Guidelines +---------- + +- **Higher value:** more conservative — requires more consecutive + stable ticks, reducing false stops; adds a small delay after motion +- **Lower value:** faster response after the saddle stops, but may + advance the wizard too early if the saddle is still settling + +Typical Range +------------- + +========= ========================================================= +Samples Behaviour +========= ========================================================= +2 Minimal confirmation — fast but may trigger on a brief lull +3 Default — suitable for most setups +4–5 More reliable on machines with resonance or encoder noise +========= ========================================================= + +Notes +----- + +- If the wizard advances before the saddle has fully stopped, increase + this value +- If the wizard is slow to advance after motion completes, reduce it +- Works in conjunction with Saddle Encoder Stability Tolerance +- Value must be a positive integer diff --git a/rcp/help/at_saddle_encoder_stability_tolerance.rst b/rcp/help/at_saddle_encoder_stability_tolerance.rst new file mode 100644 index 0000000..c576e96 --- /dev/null +++ b/rcp/help/at_saddle_encoder_stability_tolerance.rst @@ -0,0 +1,44 @@ +Saddle Encoder Stability Tolerance (Counts) +============================================ + +The maximum allowed variation in saddle encoder counts between +consecutive readings before the saddle is considered stationary. + +How It Works +------------ + +After every servo move (retract, preload, adjust), the system polls +the saddle encoder to confirm it has fully come to rest. On each poll, +it checks whether the absolute change in encoder position since the +last reading is within this tolerance. When the change stays within +tolerance for the required number of samples (see Saddle Encoder +Stability Samples), the saddle is declared stopped and the wizard +advances to the next step. + +Guidelines +---------- + +- **Smaller value:** stricter definition of "stopped" — use on rigid + machines where the saddle truly stops quickly with minimal bounce +- **Larger value:** more forgiving — useful if the encoder shows slight + jitter or the saddle takes time to settle completely + +Typical Range +------------- + +======= ==================================================== +Counts Behaviour +======= ==================================================== +1 Default — very strict; typical for clean setups +2–3 Slightly more forgiving; reduces false "still moving" +4+ Only needed if encoder is noisy or saddle bounces +======= ==================================================== + +Notes +----- + +- If the wizard seems to hang after a move and never advances, try + increasing this value by 1–2 counts +- This setting works together with Saddle Encoder Stability Samples — + both must be satisfied before the saddle is considered stopped +- Value must be a positive integer diff --git a/rcp/help/at_thread_type.rst b/rcp/help/at_thread_type.rst new file mode 100644 index 0000000..e67447c --- /dev/null +++ b/rcp/help/at_thread_type.rst @@ -0,0 +1,58 @@ +Thread Type +=========== + +The thread profile geometry — the cross-sectional shape of the +thread form being cut. + +This setting determines: + +- The formula used to auto-calculate cutting depth +- The compound infeed angle (if compound infeed mode is enabled) + +Available Types +--------------- + +ISO Metric +^^^^^^^^^^ +Standard metric fastener thread. 60° included angle (30° half-angle). +Covers all M-series screws and bolts per ISO 68-1. + +Unified +^^^^^^^ +Standard US/imperial fastener thread (UNC and UNF). Also a 60° included +angle. Per ASME B1.1. Use this for most inch-dimensioned fasteners. + +Whitworth +^^^^^^^^^ +Older British standard thread (BSW and BSF). 55° included angle +(27.5° half-angle). Used on older British-made machines and +replacement parts for vintage equipment. + +ACME +^^^^ +Trapezoidal thread profile. 29° included angle (14.5° half-angle). +Per ASME B1.5. Commonly used for lead screws, vise screws, and +other power transmission applications where self-locking and +strength are important. + +Thread Depth Formulas +--------------------- + +========== =========== ======================== +Type Factor Depth formula +========== =========== ======================== +ISO Metric 0.61343 depth = 0.61343 × pitch +Unified 0.64952 depth = 0.64952 × pitch +Whitworth 0.6403 depth = 0.6403 × pitch +ACME 0.5 depth = 0.5 × pitch +========== =========== ======================== + +*Depth is radial. If Cross Slide Diameter Mode is ON, this value is +doubled.* + +Notes +----- + +- ACME is available in both metric and imperial pitch modes +- Changing thread type updates the compound infeed angle automatically + when compound infeed mode is enabled diff --git a/rcp/help/at_threading_acceleration.rst b/rcp/help/at_threading_acceleration.rst new file mode 100644 index 0000000..d5a5257 --- /dev/null +++ b/rcp/help/at_threading_acceleration.rst @@ -0,0 +1,44 @@ +Threading Acceleration (Steps/s²) +================================== + +How quickly the servo ramps up to threading speed at the start of +each cut, and decelerates to a stop at the thread end. + +How It Works +------------ + +The servo uses a trapezoidal motion profile during the threading cut: + +1. Accelerate at this rate from rest up to the threading max speed +2. Cruise at threading max speed for the length of the thread +3. Decelerate at this rate to stop at the end position + +A higher acceleration reaches full cutting speed sooner, which is +important for short threads where a slow ramp-up would consume most +of the thread length before the servo is up to speed. + +Guidelines +---------- + +- Higher values: faster ramp-up, shorter effective lead-in — better + for short threads +- Lower values: gentler start, less mechanical stress — better for + heavy setups with large inertia +- Too high with a heavy load: risk of missed steps during the ramp + +Typical Range +------------- + +**Typical starting point:** approximately 2× your Threading Max Speed +value (e.g., max speed 1000 steps/s → acceleration 2000 steps/s²). + +Increase gradually until the ramp feels responsive without causing +missed steps. + +Notes +----- + +- Value must be a positive integer +- The same rate is used for both accelerating and decelerating +- For general servo acceleration guidance see the Servo Acceleration + help (servo_acceleration.md) in the Servo setup screen diff --git a/rcp/help/at_threading_max_speed.rst b/rcp/help/at_threading_max_speed.rst new file mode 100644 index 0000000..5d712e1 --- /dev/null +++ b/rcp/help/at_threading_max_speed.rst @@ -0,0 +1,48 @@ +Threading Max Speed (Steps/s) +============================= + +The maximum servo speed allowed during the threading cut itself. + +How It Works +------------ + +When a threading pass begins, the firmware drives the saddle at the +speed required by the spindle RPM and selected pitch. This value acts +as a hard cap — the servo will not exceed it regardless of how fast +the spindle is turning. + +The system also uses this limit to calculate the **maximum allowable +spindle RPM** for the selected pitch. If the spindle is turning too +fast when you press Cut, a warning is shown and the cut is blocked +until the spindle speed is reduced. + +Spindle Speed Limit +------------------- + +The relationship between spindle RPM and required servo speed is: + + required steps/s = (spindle RPM / 60) × pitch × steps per mm × scale/servo ratio + +A higher threading max speed allows a faster spindle, but the machine +must be rigid enough to handle the increased feed rate. + +Typical Ranges +-------------- + +=================== ====================== +Servo Max Speed Typical Threading Speed +=================== ====================== +1000 steps/s 500–1000 steps/s +2000 steps/s 1000–2000 steps/s +3000 steps/s 1500–3000 steps/s +=================== ====================== + +Notes +----- + +- Value is clamped to the servo max speed +- For coarse pitches (> 2 mm / < 13 TPI), consider a lower value to + reduce chatter and tool load +- If the system warns that your spindle is too fast, either reduce + spindle RPM or increase this setting (within your machine's limits) +- See also: Servo Max Speed in the Servo setup screen diff --git a/rcp/utils/devices.py b/rcp/utils/devices.py index f9d5228..2cc952b 100644 --- a/rcp/utils/devices.py +++ b/rcp/utils/devices.py @@ -103,6 +103,23 @@ class FastData(BaseDevice): } fastData_t; """ +class AssistedThreadingData(BaseDevice): + definition = """ +typedef struct { + uint16_t threadRequest; + uint16_t threadReset; + uint16_t spindleScaleIndex; + uint16_t threadPhaseActive; + uint16_t threadEnabled; + uint16_t spindlePhaseTolerance; + int32_t threadRemainingSteps; + uint32_t threadStartSteps; + uint32_t spindleCountsPerRev; + int32_t threadPhaseRef; + int32_t currentThreadPhase; +} assistedThreadingData_t; +""" + class Global(BaseDevice): root_structure = True @@ -114,6 +131,7 @@ class Global(BaseDevice): uint32_t executionCycles; servo_t servo; input_t scales[4]; + assistedThreadingData_t assistedThreadingData; fastData_t fastData; } rampsSharedData_t; """ diff --git a/tests/components/__init__.py b/tests/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/home/__init__.py b/tests/components/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/home/assisted_threading/__init__.py b/tests/components/home/assisted_threading/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/home/assisted_threading/conftest.py b/tests/components/home/assisted_threading/conftest.py new file mode 100644 index 0000000..2a0ae55 --- /dev/null +++ b/tests/components/home/assisted_threading/conftest.py @@ -0,0 +1,122 @@ +""" +Shared fixtures for AssistedThreadingWizard tests. + +All test modules in this directory can use make_wizard() directly — pytest +discovers conftest.py automatically and makes module-level names available +when imported explicitly. The factory is also exported as a pytest fixture. +""" + +from fractions import Fraction +from unittest.mock import MagicMock + +import pytest + +from rcp.components.home.assisted_threading.wizard import AssistedThreadingWizard + + +# --------------------------------------------------------------------------- +# Builder helpers +# --------------------------------------------------------------------------- + +def _make_input(ratioNum: int, ratioDen: int, encoderCurrent: int = 0): + """Create a mock InputDispatcher (raw encoder).""" + inp = MagicMock() + inp.ratioNum = ratioNum + inp.ratioDen = ratioDen + inp.encoderCurrent = encoderCurrent + inp.stepsPerMM = abs(ratioDen / ratioNum) + inp.inputIndex = 0 + return inp + + +def _make_axis(inp, scaledPosition: float = 0.0): + """Create a mock AxisDispatcher that delegates to *inp* via _primary_input().""" + axis = MagicMock() + axis.scaledPosition = scaledPosition + axis.offsets = [0] * 100 + axis._primary_input.return_value = inp + return axis + + +def make_wizard( + ratioNum: int = 1, + ratioDen: int = 6926, + saddle_encoderCurrent: int = 0, + cross_encoderCurrent: int = 0, + left_hand_thread: bool = False, + inner_thread: bool = False, + metric_mode: bool = True, + is_metric_format: bool = True, +): + """Create an AssistedThreadingWizard with mocked dependencies. + + Returns (wizard, bar, saddle_input, cross_input, app). + Note: saddle_scale and cross_slide_scale (AxisDispatchers) are accessible + via app.els.get_z_axis() and app.els.get_x_axis() respectively. + """ + bar = MagicMock() + bar.left_hand_thread = left_hand_thread + bar.inner_thread = inner_thread + bar.metric_mode = metric_mode + bar.start_position = 0 + bar.stop_position = 0 + bar.material_width = 0 + bar.cutting_depth = 0 + bar.last_cutting_depth = 0 + bar.reversing_speed = 500 + bar.selected_pitch = "1.5" + bar.thread_profile_type = "ISO Metric" + bar.compound_infeed_mode = False + bar.compound_infeed_offset_degrees = 1.0 + + saddle_inp = _make_input(ratioNum, ratioDen, saddle_encoderCurrent) + cross_inp = _make_input(ratioNum, ratioDen, cross_encoderCurrent) + + saddle_axis = _make_axis(saddle_inp) + cross_axis = _make_axis(cross_inp) + + els = MagicMock() + els.get_z_axis.return_value = saddle_axis + els.get_x_axis.return_value = cross_axis + els.get_spindle_axis.return_value = None # override per-test when needed + # Machine config properties (moved from bar in v1.3.0) + els.at_saddle_backlash_distance = 0.5 # mm + els.at_backlash_cushion = 0.1 # mm + els.at_metric_distances = True + els.at_threading_max_speed = 2000 + els.at_reversing_speed = 500 + els.at_cross_slide_diameter_mode = False + + board = MagicMock() + board.connected = True + board.fast_data_values = {"stepsToGo": 0, "scaleSpeed": [0] * 4, "servoCurrent": 0} + + app = MagicMock() + app.formats.factor = Fraction(1, 1) + app.formats.MM_FRACTION = Fraction(1, 1) + app.formats.INCHES_FRACTION = Fraction(10, 254) + app.formats.current_format = "MM" if is_metric_format else "IN" + app.currentOffset = 0 + app.els = els + app.board = board + + wizard = AssistedThreadingWizard.__new__(AssistedThreadingWizard) + wizard.bar = bar + wizard.app = app + wizard.servo = MagicMock() + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 1 + wizard.current_step = 0 + wizard._threading_started = False + wizard._threading_active_confirmed = False + wizard._current_callback = None + wizard._servo_watch_callback = None + wizard.manual_stop_length = None + wizard.manual_cutting_depth = None + wizard._last_saddle_encoder_value = None + wizard._stable_count = 0 + wizard._start_position_preloaded = False + wizard._isStartPositionMetricMode = True + wizard._startScaledPosition = 0.0 + + return wizard, bar, saddle_inp, cross_inp, app diff --git a/tests/components/home/assisted_threading/test_calculations.py b/tests/components/home/assisted_threading/test_calculations.py new file mode 100644 index 0000000..a8e6f6a --- /dev/null +++ b/tests/components/home/assisted_threading/test_calculations.py @@ -0,0 +1,396 @@ +""" +Unit tests for AssistedThreadingCalculationsMixin methods. +""" + +from fractions import Fraction +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 1. _convert_distance_units_to_encoder +# --------------------------------------------------------------------------- + +class TestConvertDistanceUnitsToEncoder: + def test_mm_gives_correct_counts(self): + """0.1 mm should give ~693 counts (0.1 * 6926).""" + wizard, _, _, _, app = make_wizard() + axis = app.els.get_z_axis() + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=True) + assert result == 693 # round(0.1 * 6926) + + def test_non_zero_axis_offset_does_not_contaminate(self): + """v1.3.0 fix: offset on the AxisDispatcher must NOT affect pure distance + conversion. With offset=-12.885 the result should still be ~693.""" + wizard, _, _, _, app = make_wizard() + axis = app.els.get_z_axis() + axis.offsets[0] = -12.885 + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=True) + assert abs(result) < 1_000, ( + f"Offset contamination: expected ~693, got {result}" + ) + + def test_inch_distance(self): + """0.1 inch; INCHES_FRACTION=10/254 → counts = 0.1/(10/254)*6926 ≈ 17594.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + axis = app.els.get_z_axis() + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=False) + expected = round(0.1 / (10 / 254) * 6926) + assert result == expected + + def test_negative_ratio_inverts_direction(self): + """A negative ratioNum should invert the sign of the result.""" + wizard, _, _, _, app = make_wizard(ratioNum=-1, ratioDen=6926) + axis = app.els.get_z_axis() + result = wizard._convert_distance_units_to_encoder(axis, 0.1, is_metric=True) + # (0.1 / 1.0) * (6926 / -1) = -692.6 → -693 + assert result == -693 + + +# --------------------------------------------------------------------------- +# 2. _get_saddle_scale_effective_dir +# --------------------------------------------------------------------------- + +class TestGetSaddleScaleEffectiveDir: + def test_rht_positive_ratio_gives_minus_one(self): + wizard, bar, _, _, _ = make_wizard(left_hand_thread=False) + assert wizard._get_saddle_scale_effective_dir() == -1 + + def test_lht_positive_ratio_gives_plus_one(self): + wizard, bar, _, _, _ = make_wizard(left_hand_thread=True) + assert wizard._get_saddle_scale_effective_dir() == 1 + + def test_rht_negative_ratio_gives_plus_one(self): + wizard, bar, saddle, _, _ = make_wizard(left_hand_thread=False, ratioNum=-1) + assert wizard._get_saddle_scale_effective_dir() == 1 + + def test_lht_negative_ratio_gives_minus_one(self): + wizard, bar, saddle, _, _ = make_wizard(left_hand_thread=True, ratioNum=-1) + assert wizard._get_saddle_scale_effective_dir() == -1 + + +# --------------------------------------------------------------------------- +# 3. _get_cross_slide_scale_effective_dir +# --------------------------------------------------------------------------- + +class TestGetCrossSlideScaleEffectiveDir: + def test_external_thread_positive_ratio_gives_minus_one(self): + wizard, bar, _, _, _ = make_wizard(inner_thread=False) + assert wizard._get_cross_slide_scale_effective_dir() == -1 + + def test_internal_thread_positive_ratio_gives_plus_one(self): + wizard, bar, _, _, _ = make_wizard(inner_thread=True) + assert wizard._get_cross_slide_scale_effective_dir() == 1 + + def test_external_thread_negative_ratio_gives_plus_one(self): + wizard, bar, _, cross, _ = make_wizard(inner_thread=False, ratioNum=-1) + assert wizard._get_cross_slide_scale_effective_dir() == 1 + + def test_internal_thread_negative_ratio_gives_minus_one(self): + wizard, bar, _, cross, _ = make_wizard(inner_thread=True, ratioNum=-1) + assert wizard._get_cross_slide_scale_effective_dir() == -1 + + +# --------------------------------------------------------------------------- +# 7. _get_backlash_cushion_encoder_steps / _get_saddle_backlash_distance_encoder_steps +# --------------------------------------------------------------------------- + +class TestBacklashEncoderSteps: + def test_cushion_gives_sane_counts(self): + """0.1 mm backlash cushion at 6926 counts/mm → ~693 counts.""" + wizard, _, _, _, app = make_wizard() + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + + result = wizard._get_backlash_cusion_encoder_steps() + + assert 600 < abs(result) < 800, f"Expected ~693, got {result}" + + def test_backlash_distance_gives_sane_counts(self): + """0.5 mm saddle backlash distance → ~3463 counts.""" + wizard, _, _, _, app = make_wizard() + app.els.at_saddle_backlash_distance = 0.5 + app.els.at_metric_distances = True + + result = wizard._get_saddle_backlash_distance_encoder_steps() + + assert 3000 < abs(result) < 4000, f"Expected ~3463, got {result}" + + def test_axis_offset_does_not_affect_cushion(self): + """v1.3.0 fix: DRO zero offset must NOT inflate the cushion. + With offset=-12.885 the result should still be ~693 (not ~89930).""" + wizard, _, _, _, app = make_wizard() + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + app.els.get_z_axis().offsets[0] = -12.885 + + result = wizard._get_backlash_cusion_encoder_steps() + + assert 600 < abs(result) < 800, ( + f"Offset contamination: expected ~693, got {result}" + ) + + def test_inch_backlash_cushion(self): + """0.004 inch backlash cushion with inch scale factor → ~704 counts.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + # encoder_factor = 10/254 ≈ 0.03937 + # counts = (0.004 / 0.03937) * 6926 ≈ 704 + result = wizard._get_backlash_cusion_encoder_steps() + assert 600 < abs(result) < 800, f"Expected ~704, got {result}" + + +# --------------------------------------------------------------------------- +# 8. _get_stop_position_units — live encoder vs manual entry +# --------------------------------------------------------------------------- + +class TestGetStopPositionUnits: + def test_returns_live_encoder_when_no_manual_override(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = -92812 + wizard.manual_stop_length = None + + assert wizard._get_stop_position_units() == -92812 + + def test_manual_override_converts_distance_to_encoder(self): + """User typed -14.665 mm; initial scaled position = -1.305 mm at + encoder -90140. Delta = -13.36 mm → result must be past start in -ve dir.""" + wizard, bar, _, _, app = make_wizard() + bar.start_position = -90140 + wizard._isStartPositionMetricMode = True + wizard._startScaledPosition = -1.305 + wizard.manual_stop_length = -14.665 + app.formats.current_format = "MM" + app.formats.factor = Fraction(1, 1) + app.formats.MM_FRACTION = Fraction(1, 1) + + result = wizard._get_stop_position_units() + + assert result < bar.start_position, ( + f"Manual stop {result} should be more negative than start {bar.start_position}" + ) + + +# --------------------------------------------------------------------------- +# 9. _calculate_thread_depth — all thread profiles +# --------------------------------------------------------------------------- + +class TestCalculateThreadDepth: + """Thread depth formulas (radial, metric pitch, metric display).""" + + def _w(self, pitch="1.5", profile="ISO Metric", metric_mode=True, + diameter_mode=False, is_metric_format=True): + wizard, bar, _, _, app = make_wizard(metric_mode=metric_mode, + is_metric_format=is_metric_format) + bar.selected_pitch = pitch + bar.thread_profile_type = profile + bar.metric_mode = metric_mode + app.els.at_cross_slide_diameter_mode = diameter_mode # moved to els in v1.3.0 + return wizard + + def test_iso_metric_1_5mm(self): + w = self._w("1.5", "ISO Metric") + assert abs(w._calculate_thread_depth() - 0.61343 * 1.5) < 0.001 + + def test_unified_1_5mm(self): + w = self._w("1.5", "Unified") + assert abs(w._calculate_thread_depth() - 0.64952 * 1.5) < 0.001 + + def test_whitworth_1_5mm(self): + w = self._w("1.5", "Whitworth") + assert abs(w._calculate_thread_depth() - 0.6403 * 1.5) < 0.001 + + def test_acme_1_5mm(self): + w = self._w("1.5", "ACME") + assert abs(w._calculate_thread_depth() - 0.5 * 1.5) < 0.001 + + def test_imperial_16_tpi_iso_metric(self): + """16 TPI, display in inches → depth converted to inches (÷ 25.4). + pitch_mm = 25.4/16; depth_in = 0.61343 × pitch_mm / 25.4 = 0.61343/16.""" + w = self._w("16", "ISO Metric", metric_mode=False, is_metric_format=False) + pitch_mm = 25.4 / 16 + assert abs(w._calculate_thread_depth() - 0.61343 * pitch_mm / 25.4) < 0.001 + + def test_diameter_mode_doubles_depth(self): + w = self._w("1.5", "ISO Metric", diameter_mode=True) + assert abs(w._calculate_thread_depth() - 0.61343 * 1.5 * 2) < 0.001 + + def test_empty_pitch_returns_none(self): + w = self._w("") + assert w._calculate_thread_depth() is None + + def test_zero_pitch_returns_none(self): + w = self._w("0") + assert w._calculate_thread_depth() is None + + def test_non_numeric_pitch_returns_none(self): + w = self._w("abc") + assert w._calculate_thread_depth() is None + + def test_metric_pitch_displayed_in_inches(self): + """Metric pitch but display in inches: depth converted by /25.4.""" + w = self._w("1.5", "ISO Metric", metric_mode=True, is_metric_format=False) + depth_mm = 0.61343 * 1.5 + expected_in = depth_mm / 25.4 + assert abs(w._calculate_thread_depth() - expected_in) < 0.0001 + + def test_imperial_tpi_displayed_in_mm(self): + """TPI pitch, display in MM: depth stays in mm (no extra conversion).""" + w = self._w("16", "ISO Metric", metric_mode=False, is_metric_format=True) + expected = 0.61343 * (25.4 / 16) + assert abs(w._calculate_thread_depth() - expected) < 0.001 + + +# --------------------------------------------------------------------------- +# 11. _get_threading_servo_delta_steps +# --------------------------------------------------------------------------- + +class TestGetThreadingServoDeltaSteps: + def test_delta_equals_encoder_difference_when_ratios_equal(self): + """When scale ratio == servo ratio the delta in servo steps equals + the delta in encoder counts.""" + wizard, bar, saddle_inp, _, _ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 # same ratio → conversion factor = 1 + saddle_inp.encoderCurrent = -90140 + bar.stop_position = -92812 # 2672 counts away + + result = wizard._get_threading_servo_delta_steps() + + assert result == -2672 + + def test_servo_ratio_scales_result(self): + """If scale is 1:6926 and servo is 1:1 the servo steps are much smaller + than encoder counts (factor = 1/6926).""" + wizard, bar, saddle_inp, _, _ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 1 # servo 1:1, scale 1:6926 + saddle_inp.encoderCurrent = -90140 + bar.stop_position = -92812 # delta_enc = -2672 + + # delta_steps = -2672 * (1/6926) / (1/1) = -2672/6926 ≈ -0.39 → 0 + result = wizard._get_threading_servo_delta_steps() + + assert result == 0 + + +# --------------------------------------------------------------------------- +# 16. Inch-mode coverage — backlash distance in inches +# --------------------------------------------------------------------------- + +class TestBacklashEncoderStepsInches: + """Complement to TestBacklashEncoderSteps — uses at_metric_distances=False.""" + + def test_saddle_backlash_distance_in_inches(self): + """0.02 inch saddle backlash distance. + factor = 10/254; counts = (0.02 / (10/254)) * 6926 ≈ 3518.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + app.els.at_saddle_backlash_distance = 0.02 + app.els.at_metric_distances = False + + result = wizard._get_saddle_backlash_distance_encoder_steps() + + expected = round(0.02 / (10 / 254) * 6926) + assert abs(result) == abs(expected), f"Expected {expected}, got {result}" + + def test_cushion_and_distance_proportional_in_inches(self): + """Doubling the backlash cushion value should double the encoder count.""" + wizard, _, _, _, app = make_wizard(is_metric_format=False) + app.els.at_metric_distances = False + + app.els.at_backlash_cushion = 0.004 + single = abs(wizard._get_backlash_cusion_encoder_steps()) + + app.els.at_backlash_cushion = 0.008 + doubled = abs(wizard._get_backlash_cusion_encoder_steps()) + + # Allow ±1 for integer rounding + assert abs(doubled - single * 2) <= 1 + + +# --------------------------------------------------------------------------- +# 18. Inch-mode coverage — manual stop position in inches +# --------------------------------------------------------------------------- + +class TestGetStopPositionUnitsInches: + def test_manual_override_in_inches(self): + """Start captured at -0.5124 in (≈ -90140 counts). + User enters -1.0 in → final encoder must be more negative than start.""" + wizard, bar, _, _, app = make_wizard(is_metric_format=False) + bar.start_position = -90140 + wizard._isStartPositionMetricMode = False + wizard._startScaledPosition = -90140 / (6926 * 25.4) + wizard.manual_stop_length = -1.0 + app.formats.current_format = "IN" + app.formats.factor = Fraction(10, 254) + app.formats.INCHES_FRACTION = Fraction(10, 254) + + result = wizard._get_stop_position_units() + + assert result < bar.start_position, ( + f"Manual inch stop {result} should be more negative than start {bar.start_position}" + ) + + def test_manual_override_same_units_at_start_no_conversion_applied(self): + """Start=0, both formats inches, -1.0 in → delta = -1.0in / factor * ratio.""" + wizard, bar, _, _, app = make_wizard(is_metric_format=False) + bar.start_position = 0 + wizard._isStartPositionMetricMode = False + wizard._startScaledPosition = 0.0 + wizard.manual_stop_length = -1.0 + app.formats.current_format = "IN" + app.formats.factor = Fraction(10, 254) + app.formats.INCHES_FRACTION = Fraction(10, 254) + + result = wizard._get_stop_position_units() + + expected = round(-1.0 / (10 / 254) * 6926) # ≈ -175921 + assert result == expected, f"Expected {expected}, got {result}" + + +# --------------------------------------------------------------------------- +# 19. Inch-mode coverage — _calculate_thread_depth, remaining TPI profiles +# --------------------------------------------------------------------------- + +class TestCalculateThreadDepthTpi: + """All four thread profiles with TPI input and inch display.""" + + def _w(self, pitch: str, profile: str, diameter_mode: bool = False): + wizard, bar, _, _, app = make_wizard(metric_mode=False, is_metric_format=False) + bar.selected_pitch = pitch + bar.thread_profile_type = profile + bar.metric_mode = False + app.els.at_cross_slide_diameter_mode = diameter_mode + return wizard + + def _pitch_in(self, tpi: str) -> float: + """TPI → pitch in inches (= 1/TPI).""" + return 1.0 / float(tpi) + + def test_unified_16_tpi_inch_display(self): + w = self._w("16", "Unified") + assert abs(w._calculate_thread_depth() - 0.64952 * self._pitch_in("16")) < 0.001 + + def test_whitworth_16_tpi_inch_display(self): + w = self._w("16", "Whitworth") + assert abs(w._calculate_thread_depth() - 0.6403 * self._pitch_in("16")) < 0.001 + + def test_acme_16_tpi_inch_display(self): + w = self._w("16", "ACME") + assert abs(w._calculate_thread_depth() - 0.5 * self._pitch_in("16")) < 0.001 + + def test_diameter_mode_doubles_tpi_depth(self): + w = self._w("16", "ISO Metric", diameter_mode=True) + assert abs(w._calculate_thread_depth() - 0.61343 * self._pitch_in("16") * 2) < 0.001 + + def test_zero_tpi_raises(self): + """Zero TPI → ZeroDivisionError: the code does `25.4 / tpi` before the + `pitch <= 0` guard, so zero TPI is not handled gracefully in this version. + This documents the deficiency — the fixed branch should return None instead.""" + w = self._w("0", "ISO Metric") + with pytest.raises(ZeroDivisionError): + w._calculate_thread_depth() diff --git a/tests/components/home/assisted_threading/test_compound_infeed.py b/tests/components/home/assisted_threading/test_compound_infeed.py new file mode 100644 index 0000000..b253a1d --- /dev/null +++ b/tests/components/home/assisted_threading/test_compound_infeed.py @@ -0,0 +1,370 @@ +""" +Unit tests for compound infeed mode calculations. + +Covers: +- _get_compound_angle_degrees: all thread types, offset subtraction +- _get_compound_z_offset_encoder: outer/inner magnitude equality, zero at surface, known value +- Direction: saddle shift sign for RHT/LHT and positive/negative scale +- _get_threading_servo_delta_steps: recalculated per pass, decreases when saddle shifts +- Stop-overshoot guard in _send_thread_latch +""" + +from math import radians, tan +from fractions import Fraction +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard +from rcp.components.home.assisted_threading.thread_type import ThreadType + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _w_compound( + thread_type: str = "ISO Metric", + offset_degrees: float = 1.0, + cross_encoderCurrent: int = 0, + material_width: int = 0, + inner_thread: bool = False, + left_hand_thread: bool = False, + ratioNum: int = 1, + ratioDen: int = 6926, +): + """Wizard with compound infeed enabled.""" + wizard, bar, saddle_inp, cross_inp, app = make_wizard( + ratioNum=ratioNum, + ratioDen=ratioDen, + cross_encoderCurrent=cross_encoderCurrent, + inner_thread=inner_thread, + left_hand_thread=left_hand_thread, + ) + bar.thread_profile_type = thread_type + bar.compound_infeed_mode = True + bar.compound_infeed_offset_degrees = offset_degrees + bar.material_width = material_width + return wizard, bar, saddle_inp, cross_inp, app + + +# --------------------------------------------------------------------------- +# 1. _get_compound_angle_degrees +# --------------------------------------------------------------------------- + +class TestGetCompoundAngleDegrees: + def test_iso_metric_default_offset(self): + """ISO Metric: half-angle=30°, offset=1° → effective=29°.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(29.0) + + def test_unified_default_offset(self): + """Unified: half-angle=30°, offset=1° → effective=29°.""" + wizard, bar, *_ = _w_compound("Unified", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(29.0) + + def test_whitworth_default_offset(self): + """Whitworth: half-angle=27.5°, offset=1° → effective=26.5°.""" + wizard, bar, *_ = _w_compound("Whitworth", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(26.5) + + def test_acme_default_offset(self): + """ACME: half-angle=14.5°, offset=1° → effective=13.5°.""" + wizard, bar, *_ = _w_compound("ACME", offset_degrees=1.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(13.5) + + def test_zero_offset(self): + """Zero offset → effective angle equals the half-angle.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=0.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(30.0) + + def test_max_offset(self): + """Max slider value (5°) → effective = 25° for ISO Metric.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=5.0) + assert wizard._get_compound_angle_degrees() == pytest.approx(25.0) + + def test_fractional_offset(self): + """2.5° offset → 27.5° effective for ISO Metric.""" + wizard, bar, *_ = _w_compound("ISO Metric", offset_degrees=2.5) + assert wizard._get_compound_angle_degrees() == pytest.approx(27.5) + + +# --------------------------------------------------------------------------- +# 2. _get_compound_z_offset_encoder — magnitude +# --------------------------------------------------------------------------- + +class TestGetCompoundZOffsetEncoder: + def test_disabled_returns_zero(self): + """compound_infeed_mode=False → always 0.""" + wizard, bar, saddle_inp, cross_inp, app = make_wizard(cross_encoderCurrent=-693) + bar.material_width = 0 + bar.compound_infeed_mode = False + bar.compound_infeed_offset_degrees = 1.0 + bar.thread_profile_type = "ISO Metric" + assert wizard._get_compound_z_offset_encoder() == 0 + + def test_at_material_surface_returns_zero(self): + """Cross-slide at material_width (no depth yet) → ΔZ = 0.""" + wizard, bar, *_ = _w_compound(cross_encoderCurrent=0, material_width=0) + assert wizard._get_compound_z_offset_encoder() == 0 + + def test_outer_and_inner_same_magnitude(self): + """Inner thread moves X outward (+693), outer moves X inward (-693). + Physical depth is the same → ΔZ encoder magnitude must be equal.""" + depth_enc = 693 # ~0.1 mm at 6926 counts/mm + + wizard_out, bar_out, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, inner_thread=False + ) + wizard_in, bar_in, *_ = _w_compound( + cross_encoderCurrent=depth_enc, material_width=0, inner_thread=True + ) + + z_outer = wizard_out._get_compound_z_offset_encoder() + z_inner = wizard_in._get_compound_z_offset_encoder() + + assert z_outer > 0, "Z offset should be positive (applied in threading direction)" + assert z_inner > 0, "Z offset should be positive (applied in threading direction)" + assert z_outer == z_inner, ( + f"Outer ({z_outer}) and inner ({z_inner}) Z offsets should be equal magnitude" + ) + + def test_known_value_iso_metric_1mm_0_3mm_depth(self): + """ + ISO Metric, 1mm pitch, 1° offset → compound angle = 29°. + X depth = 0.3 mm → ΔX_enc = round(0.3 * 6926) = 2078 counts + delta_x_mm = 2078 * 1.0 / (6926/1) ≈ 0.3 mm + ΔZ = 0.3 × tan(29°) ≈ 0.1663 mm + z_encoder = round(0.1663 * 6926) ≈ 1152 + """ + depth_enc = round(0.3 * 6926) # outer: moved inward + wizard, bar, saddle_inp, cross_inp, app = _w_compound( + thread_type="ISO Metric", + offset_degrees=1.0, + cross_encoderCurrent=-depth_enc, + material_width=0, + inner_thread=False, + ) + + result = wizard._get_compound_z_offset_encoder() + + delta_z_mm = 0.3 * tan(radians(29.0)) + expected = round(delta_z_mm * 6926) + assert abs(result - expected) <= 2, ( + f"Expected ~{expected}, got {result}" + ) + + def test_larger_depth_gives_larger_z_offset(self): + """Deeper X cut → proportionally larger ΔZ.""" + enc_shallow = round(0.1 * 6926) + enc_deep = round(0.3 * 6926) + + w1, *_ = _w_compound(cross_encoderCurrent=-enc_shallow, material_width=0) + w2, *_ = _w_compound(cross_encoderCurrent=-enc_deep, material_width=0) + + assert w2._get_compound_z_offset_encoder() > w1._get_compound_z_offset_encoder() + + def test_diameter_mode_gives_half_z_offset(self): + """Diameter mode: MM_FRACTION includes 2× → must halve delta_x_mm for radial depth.""" + depth_enc = round(0.3 * 6926) + + w_radius, *_ = _w_compound(cross_encoderCurrent=-depth_enc, material_width=0) + z_radius = w_radius._get_compound_z_offset_encoder() + + w_diameter, *_ = _w_compound(cross_encoderCurrent=-depth_enc, material_width=0) + w_diameter.app.els.at_cross_slide_diameter_mode = True + z_diameter = w_diameter._get_compound_z_offset_encoder() + + assert z_radius > 0 + assert z_diameter == pytest.approx(z_radius / 2, abs=2) + + +# --------------------------------------------------------------------------- +# 2b. _get_compound_z_offset_encoder — retraction past material surface +# --------------------------------------------------------------------------- + +class TestCompoundZOffsetRetraction: + def test_outer_retracted_past_surface_returns_zero(self): + """Outer thread: cross-slide moved outward past material_width (positive encoder, + opposite to cutting direction) → no compound shift.""" + depth_enc = round(0.3 * 6926) + # Outer thread with positive ratioNum: cutting direction is inward (negative encoder). + # Moving outward (+depth_enc) is a retraction past the surface. + wizard, bar, *_ = _w_compound( + cross_encoderCurrent=depth_enc, material_width=0, inner_thread=False + ) + assert wizard._get_compound_z_offset_encoder() == 0 + + def test_inner_retracted_past_surface_returns_zero(self): + """Inner thread: cross-slide moved inward past material_width (negative encoder, + opposite to cutting direction) → no compound shift.""" + depth_enc = round(0.3 * 6926) + # Inner thread with positive ratioNum: cutting direction is outward (positive encoder). + # Moving inward (-depth_enc) is a retraction past the surface. + wizard, bar, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, inner_thread=True + ) + assert wizard._get_compound_z_offset_encoder() == 0 + + +# --------------------------------------------------------------------------- +# 3. Direction: ΔZ applied in saddle threading direction +# --------------------------------------------------------------------------- + +class TestCompoundZDirection: + def test_rht_positive_scale_z_applied_in_negative_direction(self): + """RHT + positive scale → effective_dir = -1 → target = start + (-1 * z_offset).""" + depth_enc = round(0.3 * 6926) + wizard, bar, saddle_inp, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, + left_hand_thread=False, ratioNum=1, ratioDen=6926, + ) + bar.start_position = 0 + + z_offset = wizard._get_compound_z_offset_encoder() + effective_dir = wizard._get_saddle_scale_effective_dir() + + target = bar.start_position + effective_dir * z_offset + assert target < bar.start_position, ( + f"RHT saddle should move negative (toward chuck), got target={target}" + ) + + def test_lht_positive_scale_z_applied_in_positive_direction(self): + """LHT + positive scale → effective_dir = +1 → target = start + (+1 * z_offset).""" + depth_enc = round(0.3 * 6926) + wizard, bar, saddle_inp, *_ = _w_compound( + cross_encoderCurrent=-depth_enc, material_width=0, + left_hand_thread=True, ratioNum=1, ratioDen=6926, + ) + bar.start_position = 0 + + z_offset = wizard._get_compound_z_offset_encoder() + effective_dir = wizard._get_saddle_scale_effective_dir() + + target = bar.start_position + effective_dir * z_offset + assert target > bar.start_position, ( + f"LHT saddle should move positive (away from chuck), got target={target}" + ) + + def test_rht_negative_scale_z_applied_in_positive_direction(self): + """RHT + negative scale → effective_dir = +1. + With ratioNum=-1 on outer threading, cross_dir = +1, so encoder must + INCREASE to cut deeper. Use +depth_enc to represent a real cutting depth.""" + depth_enc = round(0.3 * 6926) + wizard, bar, *_ = _w_compound( + cross_encoderCurrent=+depth_enc, material_width=0, + left_hand_thread=False, ratioNum=-1, ratioDen=6926, + ) + bar.start_position = 0 + + z_offset = wizard._get_compound_z_offset_encoder() + effective_dir = wizard._get_saddle_scale_effective_dir() + + target = bar.start_position + effective_dir * z_offset + assert target > bar.start_position + + +# --------------------------------------------------------------------------- +# 4. threadRemainingSteps recalculated per pass +# --------------------------------------------------------------------------- + +class TestThreadRemainingStepsRecalculated: + def test_steps_from_start_to_stop(self): + """Saddle at start_position=0, stop=-6926 (1 mm). With equal ratios, + delta_steps == delta_enc.""" + wizard, bar, saddle_inp, *_ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + saddle_inp.encoderCurrent = 0 + bar.start_position = 0 + bar.stop_position = -6926 + + result = wizard._get_threading_servo_delta_steps() + + assert result == -6926 + + def test_steps_decrease_when_saddle_shifted_toward_stop(self): + """After a ΔZ move, saddle is closer to stop → threadRemainingSteps smaller.""" + wizard, bar, saddle_inp, *_ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + bar.stop_position = -6926 + + saddle_inp.encoderCurrent = 0 + steps_before = wizard._get_threading_servo_delta_steps() + + saddle_inp.encoderCurrent = -100 # shifted 100 counts toward stop + steps_after = wizard._get_threading_servo_delta_steps() + + assert abs(steps_after) < abs(steps_before), ( + f"Steps should decrease after Z shift: before={steps_before}, after={steps_after}" + ) + + def test_steps_change_proportional_to_z_shift(self): + """Step delta difference equals the encoder shift.""" + wizard, bar, saddle_inp, *_ = make_wizard(ratioNum=1, ratioDen=6926) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + bar.stop_position = -100_000 + + saddle_inp.encoderCurrent = 0 + steps_before = wizard._get_threading_servo_delta_steps() + + shift = 500 + saddle_inp.encoderCurrent = -shift + steps_after = wizard._get_threading_servo_delta_steps() + + assert abs(steps_before) - abs(steps_after) == shift + + +# --------------------------------------------------------------------------- +# 5. Stop-overshoot guard in _send_thread_latch +# --------------------------------------------------------------------------- + +class TestStopOvershootGuard: + def _wizard_for_latch(self, saddle_current: int, stop_position: int, left_hand_thread: bool = False): + wizard, bar, saddle_inp, cross_inp, app = make_wizard( + ratioNum=1, ratioDen=6926, left_hand_thread=left_hand_thread + ) + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + saddle_inp.encoderCurrent = saddle_current + bar.start_position = 0 + bar.stop_position = stop_position + bar.compound_infeed_mode = False # guard is mode-independent + wizard._threading_started = False + wizard._threading_active_confirmed = False + return wizard, bar, app + + def test_no_guard_when_saddle_before_stop(self): + """Saddle at 0, stop at -6926 (RHT) → remaining > 0, no popup.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, app = self._wizard_for_latch(0, -6926) + app.board.device = MagicMock() + wizard._send_thread_latch() + + CustomPopup.assert_not_called() + + def test_guard_triggers_when_saddle_at_stop(self): + """Saddle exactly at stop → remaining == 0 → popup shown.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, app = self._wizard_for_latch(-6926, -6926) + app.board.device = MagicMock() + wizard._send_thread_latch() + + CustomPopup.assert_called_once() + + def test_guard_triggers_when_saddle_past_stop(self): + """Saddle past stop (RHT: more negative than stop) → popup shown.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, app = self._wizard_for_latch(-7000, -6926) + app.board.device = MagicMock() + wizard._send_thread_latch() + + CustomPopup.assert_called_once() diff --git a/tests/components/home/assisted_threading/test_motion.py b/tests/components/home/assisted_threading/test_motion.py new file mode 100644 index 0000000..b55aaad --- /dev/null +++ b/tests/components/home/assisted_threading/test_motion.py @@ -0,0 +1,133 @@ +""" +Unit tests for AssistedThreadingMotionMixin methods. +""" + +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 10. _encoder_is_stable — full state machine +# --------------------------------------------------------------------------- + +class TestEncoderIsStable: + def test_first_call_returns_false_and_initialises(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = 100 + wizard._reset_encoder_stability_check() + + assert wizard._encoder_is_stable(tolerance=5, samples=3) is False + assert wizard._last_saddle_encoder_value == 100 + assert wizard._stable_count == 0 + + def test_stable_for_n_samples_returns_true(self): + wizard, _, saddle_inp, _, _ = make_wizard() + wizard._reset_encoder_stability_check() + saddle_inp.encoderCurrent = 100 + + wizard._encoder_is_stable(5, 3) # initialise + wizard._encoder_is_stable(5, 3) + wizard._encoder_is_stable(5, 3) + result = wizard._encoder_is_stable(5, 3) + + assert result is True + assert wizard._stable_count >= 3 + + def test_jump_outside_tolerance_resets_count(self): + wizard, _, saddle_inp, _, _ = make_wizard() + wizard._reset_encoder_stability_check() + saddle_inp.encoderCurrent = 100 + + wizard._encoder_is_stable(5, 3) # initialise + wizard._encoder_is_stable(5, 3) # count=1 + + saddle_inp.encoderCurrent = 200 # jump > 5 + result = wizard._encoder_is_stable(5, 3) + + assert result is False + assert wizard._stable_count == 0 + + def test_within_tolerance_does_not_reset(self): + wizard, _, saddle_inp, _, _ = make_wizard() + wizard._reset_encoder_stability_check() + saddle_inp.encoderCurrent = 100 + + wizard._encoder_is_stable(5, 3) # init + saddle_inp.encoderCurrent = 103 # within 5 + wizard._encoder_is_stable(5, 3) # count=1 + saddle_inp.encoderCurrent = 105 # within 5 of 103 + wizard._encoder_is_stable(5, 3) # count=2 + + assert wizard._stable_count == 2 + + def test_reset_clears_state(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = 100 + wizard._encoder_is_stable(5, 3) # prime some state + + wizard._reset_encoder_stability_check() + + assert wizard._last_saddle_encoder_value is None + assert wizard._stable_count == 0 + + +# --------------------------------------------------------------------------- +# TestFinishGoToStart (new) +# --------------------------------------------------------------------------- + +class TestFinishGoToStart: + """Tests for _finish_go_to_start() in AssistedThreadingMotionMixin.""" + + def test_not_at_depth_goes_to_next_step(self): + """When cross slide is NOT at final cutting depth, advance by 1 step.""" + wizard, _, _, _, _ = make_wizard() + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._reset_servo_watch_callback = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=False) + + wizard._finish_go_to_start() + + wizard.goto_step.assert_called_once_with(6) + + def test_at_depth_skips_to_step_8(self): + """When cross slide IS at final cutting depth, skip step 7 → go to step 8.""" + wizard, _, _, _, _ = make_wizard() + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._reset_servo_watch_callback = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=True) + + wizard._finish_go_to_start() + + wizard.goto_step.assert_called_once_with(7) + + def test_sets_preloaded_flag(self): + """_start_position_preloaded must be True after the call.""" + wizard, _, _, _, _ = make_wizard() + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._reset_servo_watch_callback = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=False) + wizard._start_position_preloaded = False + + wizard._finish_go_to_start() + + assert wizard._start_position_preloaded is True + + def test_resets_servo_watch_callback(self): + """The servo watch callback must be cleared before advancing.""" + wizard, _, _, _, app = make_wizard() + cb = MagicMock() + wizard._servo_watch_callback = cb + wizard.current_step = 5 + wizard.goto_step = MagicMock() + wizard._is_cross_slide_at_final_cutting_depth = MagicMock(return_value=False) + + wizard._finish_go_to_start() + + assert wizard._servo_watch_callback is None + app.board.unbind.assert_called_once_with(update_tick=cb) diff --git a/tests/components/home/assisted_threading/test_safety.py b/tests/components/home/assisted_threading/test_safety.py new file mode 100644 index 0000000..c7a66b0 --- /dev/null +++ b/tests/components/home/assisted_threading/test_safety.py @@ -0,0 +1,408 @@ +""" +Unit tests for AssistedThreadingSafetyMixin methods. +""" + +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 4. _is_valid_stop_position +# --------------------------------------------------------------------------- + +class TestIsValidStopPosition: + def test_rht_saddle_moved_past_cushion_returns_true(self): + """RHT, saddle moved 2672 counts (-0.39 mm) past start — beyond the + 0.1 mm cushion (693 counts), so the stop is valid.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = -92812 # 2672 counts past start in -ve dir + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + def test_rht_saddle_not_moved_enough_returns_false(self): + """RHT, saddle moved only 100 counts — less than 693-count cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = -90240 # only 100 counts past start + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is False + + def test_lht_stop_must_be_in_positive_direction(self): + """LHT: effective_dir=+1, so stop must be greater than start+cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=True) + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 5000 # well past cushion in +ve dir + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + def test_stop_exactly_at_min_stop_returns_false(self): + """Stop equal to min_stop gives (stop - min_stop)*dir == 0 → False.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # 693 counts + app.els.at_metric_distances = True + # effective_dir = -1, min_stop = 0 + (-1)*693 = -693 + saddle_inp.encoderCurrent = -693 # exactly at min_stop + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is False + + def test_axis_offset_does_not_affect_stop_validation(self): + """v1.3.0 fix: DRO zero offset on the axis must NOT inflate the cushion. + With offset=-12.885 a legitimate 2672-count stop must still be valid.""" + wizard, bar, saddle_inp, _, app = make_wizard(left_hand_thread=False) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.1 + app.els.at_metric_distances = True + axis = app.els.get_z_axis() + axis.offsets[0] = -12.885 # simulate DRO zero + saddle_inp.encoderCurrent = -92812 + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + +# --------------------------------------------------------------------------- +# 12. _is_cross_slide_retracted +# --------------------------------------------------------------------------- + +class TestIsCrossSlideRetracted: + def test_saddle_not_beyond_start_returns_true_without_checking_cross(self): + """Saddle hasn't moved past start → True (cross slide check skipped).""" + wizard, bar, saddle_inp, _, _ = make_wizard(left_hand_thread=False) + bar.start_position = 0 + # effective_dir = -1 (RHT), "beyond start" means negative encoder + saddle_inp.encoderCurrent = 100 # positive — NOT beyond start for RHT + + assert wizard._is_cross_slide_retracted() is True + + def test_saddle_beyond_start_cross_retracted_returns_true(self): + """Saddle past start; cross slide moved in retract direction → True.""" + wizard, bar, saddle_inp, cross_inp, _ = make_wizard( + left_hand_thread=False, # saddle effective_dir=-1 + inner_thread=False, # cross effective_dir=-1, retract_dir=+1 + ) + bar.start_position = 0 + saddle_inp.encoderCurrent = -1000 # beyond start (RHT) + bar.material_width = 0 + cross_inp.encoderCurrent = 500 # moved +500 from material_width → retracted + + assert wizard._is_cross_slide_retracted() is True + + def test_saddle_beyond_start_cross_not_retracted_returns_false(self): + """Saddle past start; cross slide still in cutting direction → False.""" + wizard, bar, saddle_inp, cross_inp, _ = make_wizard( + left_hand_thread=False, + inner_thread=False, + ) + bar.start_position = 0 + saddle_inp.encoderCurrent = -1000 + bar.material_width = 0 + cross_inp.encoderCurrent = -500 # moved in cutting direction → not retracted + + assert wizard._is_cross_slide_retracted() is False + + +# --------------------------------------------------------------------------- +# 13. _is_cross_slide_at_final_cutting_depth +# --------------------------------------------------------------------------- + +class TestIsCrossSlideAtFinalCuttingDepth: + def test_at_depth_returns_true(self): + """External thread (inner=False, ratio +ve) → effective_dir=-1. + last_cutting_depth more negative than cutting_depth → at depth.""" + wizard, bar, _, cross, _ = make_wizard(inner_thread=False) + bar.last_cutting_depth = -1000 # deeper (more negative) + bar.cutting_depth = -800 + + # (last - cutting) * dir = (-1000 - -800) * -1 = 200 >= 0 → True + assert wizard._is_cross_slide_at_final_cutting_depth() is True + + def test_not_yet_at_depth_returns_false(self): + wizard, bar, _, cross, _ = make_wizard(inner_thread=False) + bar.last_cutting_depth = -500 + bar.cutting_depth = -800 + + # (-500 - -800) * -1 = -300 < 0 → False + assert wizard._is_cross_slide_at_final_cutting_depth() is False + + def test_exactly_at_depth_returns_true(self): + """Equal values → difference is 0 → 0 * dir = 0 >= 0 → True.""" + wizard, bar, _, cross, _ = make_wizard(inner_thread=False) + bar.last_cutting_depth = -800 + bar.cutting_depth = -800 + + assert wizard._is_cross_slide_at_final_cutting_depth() is True + + def test_internal_thread_flips_direction(self): + """Internal thread → effective_dir=+1, so cutting goes in + direction.""" + wizard, bar, _, cross, _ = make_wizard(inner_thread=True) + bar.last_cutting_depth = 1000 # deeper in + direction + bar.cutting_depth = 800 + + # (1000 - 800) * 1 = 200 >= 0 → True + assert wizard._is_cross_slide_at_final_cutting_depth() is True + + +# --------------------------------------------------------------------------- +# 14. _check_spindle_speed_for_pitch +# --------------------------------------------------------------------------- + +class TestCheckSpindleSpeedForPitch: + """Uses servo ratioDen=6926 (same as scale) so scale/servo ratio = 1.""" + + def _make_spindle_wizard(self, spindle_speed: int, pitch: str = "1.5", + max_speed: int = 2000): + wizard, bar, saddle_inp, _, app = make_wizard() + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + + spindle_inp = MagicMock() + spindle_inp.ratioDen = 1000 # 1000 counts per revolution + spindle_inp.inputIndex = 0 + spindle_axis = MagicMock() + spindle_axis._primary_input.return_value = spindle_inp + app.els.get_spindle_axis.return_value = spindle_axis + app.board.fast_data_values = {"scaleSpeed": [spindle_speed, 0, 0, 0]} + + bar.selected_pitch = pitch + bar.metric_mode = True + app.els.at_threading_max_speed = max_speed + saddle_inp.stepsPerMM = 6926 # already set by factory but make explicit + + return wizard + + def test_spindle_too_fast_returns_false(self): + """300 steps/s / 1000 cpr = 0.3 rev/s × 1.5mm × 6926 = 3117 steps/s > 2000.""" + wizard = self._make_spindle_wizard(spindle_speed=300) + assert wizard._check_spindle_speed_for_pitch() is False + + def test_spindle_within_limit_returns_true(self): + """100 steps/s / 1000 = 0.1 rev/s × 1.5mm × 6926 = 1039 steps/s < 2000.""" + wizard = self._make_spindle_wizard(spindle_speed=100) + assert wizard._check_spindle_speed_for_pitch() is True + + def test_no_spindle_scale_returns_true(self): + """No spindle configured → skip check.""" + wizard, _, _, _, app = make_wizard() + app.els.get_spindle_axis.return_value = None + + assert wizard._check_spindle_speed_for_pitch() is True + + def test_empty_pitch_returns_true(self): + """Empty pitch string → skip check.""" + wizard = self._make_spindle_wizard(spindle_speed=300, pitch="") + assert wizard._check_spindle_speed_for_pitch() is True + + +# --------------------------------------------------------------------------- +# 17. Inch-mode coverage — _is_valid_stop_position with inch cushion +# --------------------------------------------------------------------------- + +class TestIsValidStopPositionInches: + def test_rht_inch_cushion_saddle_past_cushion_returns_true(self): + """RHT, saddle moved 2672 counts. 0.004 inch cushion ≈ 704 counts → valid.""" + wizard, bar, saddle_inp, _, app = make_wizard( + left_hand_thread=False, is_metric_format=False + ) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + saddle_inp.encoderCurrent = -92812 # 2672 counts past start + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + def test_rht_inch_cushion_saddle_not_moved_enough_returns_false(self): + """RHT, saddle moved only 100 counts — less than the 704-count cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard( + left_hand_thread=False, is_metric_format=False + ) + bar.start_position = -90140 + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + saddle_inp.encoderCurrent = -90240 # only 100 counts + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is False + + def test_lht_inch_cushion_valid_stop_in_positive_direction(self): + """LHT (effective_dir=+1), stop 5000 counts in +ve direction, 704-count cushion.""" + wizard, bar, saddle_inp, _, app = make_wizard( + left_hand_thread=True, is_metric_format=False + ) + bar.start_position = 0 + app.els.at_backlash_cushion = 0.004 + app.els.at_metric_distances = False + saddle_inp.encoderCurrent = 5000 + wizard.manual_stop_length = None + + assert wizard._is_valid_stop_position() is True + + +# --------------------------------------------------------------------------- +# 20. Inch-mode coverage — spindle speed check with TPI pitch +# --------------------------------------------------------------------------- + +class TestCheckSpindleSpeedForPitchTpi: + def _make_tpi_wizard(self, spindle_speed: int, tpi: str = "16", + max_speed: int = 2000): + wizard, bar, saddle_inp, _, app = make_wizard() + wizard.servo.ratioNum = 1 + wizard.servo.ratioDen = 6926 + + spindle_inp = MagicMock() + spindle_inp.ratioDen = 1000 + spindle_inp.inputIndex = 0 + spindle_axis = MagicMock() + spindle_axis._primary_input.return_value = spindle_inp + app.els.get_spindle_axis.return_value = spindle_axis + app.board.fast_data_values = {"scaleSpeed": [spindle_speed, 0, 0, 0]} + + bar.selected_pitch = tpi + bar.metric_mode = False + app.els.at_threading_max_speed = max_speed + saddle_inp.stepsPerMM = 6926 + + return wizard + + def test_tpi_pitch_too_fast_returns_false(self): + """300 steps/s / 1000 cpr = 0.3 rev/s × 1.5875mm × 6926 = 3298 steps/s > 2000.""" + wizard = self._make_tpi_wizard(spindle_speed=300) + assert wizard._check_spindle_speed_for_pitch() is False + + def test_tpi_pitch_within_limit_returns_true(self): + """50 steps/s / 1000 = 0.05 rev/s × 1.5875mm × 6926 = 550 steps/s < 2000.""" + wizard = self._make_tpi_wizard(spindle_speed=50) + assert wizard._check_spindle_speed_for_pitch() is True + + def test_tpi_boundary_exactly_at_limit_returns_true(self): + """Required == max_speed: condition is `required > max`, so equal → True.""" + wizard = self._make_tpi_wizard(spindle_speed=181) + assert wizard._check_spindle_speed_for_pitch() is True + + def test_non_numeric_tpi_skips_check(self): + """Unparseable TPI value → skips check and returns True.""" + wizard = self._make_tpi_wizard(spindle_speed=9999, tpi="bad") + assert wizard._check_spindle_speed_for_pitch() is True + + +# --------------------------------------------------------------------------- +# TestCheckValidStartPosition (new) +# --------------------------------------------------------------------------- + +class TestCheckValidStartPosition: + """Tests for _check_valid_start_position() in AssistedThreadingSafetyMixin.""" + + def test_within_cushion_returns_true(self): + """Saddle delta < backlash cushion → True, no popup opened.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, saddle_inp, _, app = make_wizard() + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # ~693 counts + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 100 # 100 counts delta — well within cushion + + result = wizard._check_valid_start_position() + + assert result is True + CustomPopup.assert_not_called() + + def test_outside_cushion_returns_false_and_opens_popup(self): + """Saddle delta > backlash cushion → False, CustomPopup instantiated.""" + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, bar, saddle_inp, _, app = make_wizard() + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # ~693 counts + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 5000 # 5000 counts — far outside cushion + + result = wizard._check_valid_start_position() + + assert result is False + CustomPopup.assert_called_once() + + def test_exactly_at_boundary_returns_true(self): + """delta == cushion → True (condition is strict `>`, not `>=`).""" + wizard, bar, saddle_inp, _, app = make_wizard() + bar.start_position = 0 + app.els.at_backlash_cushion = 0.1 # 693 counts + app.els.at_metric_distances = True + saddle_inp.encoderCurrent = 693 # exactly at cushion boundary + + result = wizard._check_valid_start_position() + + assert result is True + + +# --------------------------------------------------------------------------- +# TestCheckSpindleTurningForward (new) +# --------------------------------------------------------------------------- + +class TestCheckSpindleTurningForward: + """Tests for _check_spindle_turning_forward() in AssistedThreadingSafetyMixin.""" + + def _setup(self, spindle_speed: int): + wizard, _, _, _, app = make_wizard() + spindle_inp = MagicMock() + spindle_inp.inputIndex = 0 + spindle_axis = MagicMock() + spindle_axis._primary_input.return_value = spindle_inp + app.els.get_spindle_axis.return_value = spindle_axis + app.board.fast_data_values = { + "scaleSpeed": [spindle_speed, 0, 0, 0], + "stepsToGo": 0, + "servoCurrent": 0, + } + return wizard + + def test_positive_speed_returns_true(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard = self._setup(spindle_speed=100) + assert wizard._check_spindle_turning_forward() is True + CustomPopup.assert_not_called() + + def test_zero_speed_returns_false(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard = self._setup(spindle_speed=0) + assert wizard._check_spindle_turning_forward() is False + CustomPopup.assert_called_once() + + def test_negative_speed_returns_false(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard = self._setup(spindle_speed=-50) + assert wizard._check_spindle_turning_forward() is False + CustomPopup.assert_called_once() + + def test_no_spindle_axis_returns_false(self): + from rcp.components.widgets.custom_popup import CustomPopup + CustomPopup.reset_mock() + + wizard, _, _, _, app = make_wizard() + app.els.get_spindle_axis.return_value = None + + assert wizard._check_spindle_turning_forward() is False + CustomPopup.assert_called_once() diff --git a/tests/components/home/assisted_threading/test_wizard.py b/tests/components/home/assisted_threading/test_wizard.py new file mode 100644 index 0000000..07b20a3 --- /dev/null +++ b/tests/components/home/assisted_threading/test_wizard.py @@ -0,0 +1,204 @@ +""" +Unit tests for AssistedThreadingWizard core methods (lifecycle, step management). +""" + +from unittest.mock import MagicMock + +import pytest + +from tests.components.home.assisted_threading.conftest import make_wizard + + +# --------------------------------------------------------------------------- +# 5. _capture_initial_position +# --------------------------------------------------------------------------- + +class TestCaptureInitialPosition: + def test_records_encoder_and_scaled_position(self): + wizard, bar, saddle_inp, _, app = make_wizard() + saddle_inp.encoderCurrent = -90140 + app.els.get_z_axis().scaledPosition = -13.0 # scaledPosition on AxisDispatcher + + wizard._capture_initial_position() + + assert bar.start_position == -90140 + assert wizard._isStartPositionMetricMode is True + assert wizard._startScaledPosition == -13.0 + + def test_records_inch_format_flag(self): + wizard, bar, saddle_inp, _, _ = make_wizard(is_metric_format=False) + saddle_inp.encoderCurrent = 500 + + wizard._capture_initial_position() + + assert wizard._isStartPositionMetricMode is False + assert bar.start_position == 500 + + def test_returns_true_to_advance_step(self): + wizard, _, saddle_inp, _, _ = make_wizard() + saddle_inp.encoderCurrent = 0 + + result = wizard._capture_initial_position() + + assert result is True + + +# --------------------------------------------------------------------------- +# 6. _capture_material_width_position +# --------------------------------------------------------------------------- + +class TestCaptureMaterialWidthPosition: + def test_records_cross_encoder_as_material_width(self): + wizard, bar, _, cross_inp, _ = make_wizard() + cross_inp.encoderCurrent = 5000 + + wizard._capture_material_width_position() + + assert bar.material_width == 5000 + + def test_initialises_last_cutting_depth_to_material_width(self): + wizard, bar, _, cross_inp, _ = make_wizard() + cross_inp.encoderCurrent = 5000 + + wizard._capture_material_width_position() + + assert bar.last_cutting_depth == 5000 + + def test_returns_true_to_advance_step(self): + wizard, _, _, cross_inp, _ = make_wizard() + cross_inp.encoderCurrent = 0 + + result = wizard._capture_material_width_position() + + assert result is True + + +# --------------------------------------------------------------------------- +# 15. stop() — resets all wizard state +# --------------------------------------------------------------------------- + +class TestStop: + def test_stop_resets_running_state(self): + wizard, _, _, _, app = make_wizard() + app.board.connected = True + wizard._threading_started = True + wizard._current_callback = MagicMock() + + wizard.stop() + + assert wizard._threading_started is False + assert wizard._current_callback is None + + def test_stop_clears_bar_labels(self): + wizard, bar, _, _, app = make_wizard() + app.board.connected = False + + wizard.stop() + + assert bar.label_text == "" + assert bar.action_button_condition_fn is None + assert bar.retract_button_visible is False + + def test_stop_when_disconnected_does_not_write_device(self): + wizard, _, _, _, app = make_wizard() + app.board.connected = False + + wizard.stop() + + app.board.device.__getitem__.assert_not_called() + + def test_stop_resets_servo_watch_callback(self): + wizard, _, _, _, app = make_wizard() + app.board.connected = False + cb = MagicMock() + wizard._servo_watch_callback = cb + + wizard.stop() + + assert wizard._servo_watch_callback is None + app.board.unbind.assert_called_once_with(update_tick=cb) + + +# --------------------------------------------------------------------------- +# TestGotoNextStep (new) +# --------------------------------------------------------------------------- + +class TestGotoNextStep: + """Tests for goto_next_step() in AssistedThreadingWizard.""" + + def test_callback_true_advances(self): + """Callback returns True + bar.is_running=True → goto_step(current+1).""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 3 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = MagicMock(return_value=True) + + wizard.goto_next_step() + + wizard.goto_step.assert_called_once_with(4) + + def test_callback_false_stays(self): + """Callback returns False → step must NOT advance.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 3 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = MagicMock(return_value=False) + + wizard.goto_next_step() + + wizard.goto_step.assert_not_called() + + def test_callback_none_advances(self): + """Callback returns None (not False) → advances.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 2 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = MagicMock(return_value=None) + + wizard.goto_next_step() + + wizard.goto_step.assert_called_once_with(3) + + def test_no_callback_advances(self): + """No callback set → advances directly.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 1 + wizard.goto_step = MagicMock() + bar.is_running = True + wizard._current_callback = None + + wizard.goto_next_step() + + wizard.goto_step.assert_called_once_with(2) + + def test_bar_not_running_does_not_advance(self): + """Callback returns True but bar.is_running=False → no advance.""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 3 + wizard.goto_step = MagicMock() + bar.is_running = False + wizard._current_callback = MagicMock(return_value=True) + + wizard.goto_next_step() + + wizard.goto_step.assert_not_called() + + def test_past_last_step_calls_stop(self): + """Advancing past the last valid step index triggers stop().""" + wizard, bar, _, _, _ = make_wizard() + wizard.current_step = 7 # last index (0-based, 8 steps) + bar.is_running = True + wizard._current_callback = MagicMock(return_value=True) + + # goto_step(8) calls stop() because 8 >= len(self._steps) + # We need the real goto_step but mock stop() + wizard.stop = MagicMock() + # Rebuild the steps list so goto_step works without Kivy + wizard._steps = [MagicMock() for _ in range(8)] + + wizard.goto_next_step() + + wizard.stop.assert_called_once() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..da9ea36 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +""" +Shared pytest configuration. + +Installs lightweight stubs into sys.modules before any test module imports +code that depends on Kivy. This lets pure-logic unit tests run without a +display or the full Kivy runtime. + +Strategy +-------- +* Stub only *specific* submodules (e.g. ``kivy.logger``), never the top-level + ``kivy`` package itself. Replacing ``sys.modules["kivy"]`` with a plain + ModuleType makes it "not a package", which prevents *all* kivy submodule + imports and breaks tests that need real Kivy (dispatchers, screens, etc.). +* Stub ``rcp.*`` modules whose import chains would otherwise drag in Kivy UI + classes (boxlayout, popup, etc.) that require a display or OpenGL. +* Leave ``rcp.utils.devices`` un-stubbed: it only uses kivy.logger (stubbed + above) and is safe to import headlessly. +""" + +import sys +import types +from unittest.mock import MagicMock + + +def _install_kivy_stubs() -> None: + """Populate sys.modules with minimal stubs.""" + + def _mock_module(name: str, **attrs) -> types.ModuleType: + mod = types.ModuleType(name) + for k, v in attrs.items(): + setattr(mod, k, v) + sys.modules[name] = mod + return mod + + # kivy.logger — used by every rcp module for Logger.getChild(...) + # Stub the submodule directly; do NOT replace sys.modules["kivy"] itself + # (that would make kivy "not a package" and break dispatcher/screen tests). + mock_logger = MagicMock() + mock_logger.getChild.side_effect = lambda name: MagicMock() + _mock_module( + "kivy.logger", + Logger=mock_logger, + LOG_LEVELS={}, + logger_config_update=MagicMock(), + file_log_handler=MagicMock(), + ) + + # rcp.components.widgets.custom_popup — stubbing the whole module prevents + # its import chain (kivy.properties, kivy.uix.*) from running, which + # would fail without a display. + _mock_module( + "rcp.components.widgets.custom_popup", + CustomPopup=MagicMock(), + ) + + +_install_kivy_stubs() diff --git a/uv.lock b/uv.lock index 4933c65..6b10b8f 100644 --- a/uv.lock +++ b/uv.lock @@ -1490,7 +1490,7 @@ wheels = [ [[package]] name = "rcp" -version = "1.3.0rc16" +version = "1.3.0rc23" source = { editable = "." } dependencies = [ { name = "aiohttp" },