import re from decimal import Decimal, getcontext import decimal # Define interpolation and movement commands interpolation_commands = {"G01", "G02", "G03"} movement_commands = {"G00"} # Define a pattern to recognize common G-code commands gcode_pattern = re.compile( r"(G\d+|M\d+|X[-+]?\d*\.?\d+|Y[-+]?\d*\.?\d+|" r"Z[-+]?\d*\.?\d+|I[-+]?\d*\.?\d+|J[-+]?\d*\.?\d+|" r"F[-+]?\d*\.?\d+|S[-+]?\d*\.?\d+)" ) def standardize_codes(line): """ Standardizes M-codes and G-codes to two digits by adding a leading zero if necessary. """ line = re.sub(r"\b(M|G)(\d)\b", r"\g<1>0\2", line) return line def remove_comments(line): """ Removes comments from a G-code line. Supports both ';' and '()' style comments. """ # Remove anything after a ';' line = line.split(';')[0] # Remove anything inside parentheses '()' line = re.sub(r'\(.*?\)', '', line) return line.strip() def preprocess_gcode(gcode): """ Removes comments from the G-code and returns a list of tuples (original_line_number, cleaned_line). Includes all lines to maintain accurate line numbering. """ cleaned_lines = [] lines = gcode.splitlines() for idx, line in enumerate(lines): original_line_number = idx + 1 # Line numbers start from 1 line = standardize_codes(line.strip()) # Remove comments line_no_comments = remove_comments(line) # Include all lines to maintain accurate line numbering cleaned_lines.append((original_line_number, line_no_comments)) return cleaned_lines def check_required_gcodes(lines_with_numbers): """ Checks that the G-code contains required G-codes: G20/G21, G90/G91, G54-G59, and G17. Returns a list of errors with individual entries for each missing group. """ required_groups = { "units": {"G20", "G21"}, # Metric or Imperial Units "mode": {"G90", "G91"}, # Absolute or Incremental Mode "work_coordinates": {"G54", "G55", "G56", "G57", "G58", "G59"}, # Work Offsets "plane": {"G17", "G18", "G19"}, # Selected Plane } # Create a set to track found codes and their line numbers found_codes = {} for original_line_number, line in lines_with_numbers: tokens = line.split() for token in tokens: found_codes.setdefault(token, original_line_number) # Record the line number where the code was found # List to hold individual errors for each missing group missing_group_errors = [] # Check for presence of required codes for category, codes in required_groups.items(): # Only flag as missing if both options in a group are absent found = any(code in found_codes for code in codes) if not found: missing_codes = "/".join(sorted(codes)) # Assume missing codes should be on the first line where G-codes start for original_line_number, line in lines_with_numbers: if gcode_pattern.search(line): missing_group_errors.append((original_line_number, f"(Error) Missing required G-codes: ({category}) {missing_codes}")) break else: # Default to line 1 if no G-code commands are found missing_group_errors.append((1, f"(Error) Missing required G-codes: ({category}) {missing_codes}")) return missing_group_errors def check_required_gcodes_position(lines_with_numbers): """ Ensures required G-codes appear before movement commands. Flags changes in critical settings (e.g., units) after movement commands. """ issues = [] movement_seen = False required_groups = { "units": {"G20", "G21"}, "mode": {"G90", "G91"}, "work_coordinates": {"G54", "G55", "G56", "G57", "G58", "G59"}, "plane": {"G17", "G18", "G19"}, } critical_gcodes = { "units": {"G20", "G21"}, "plane": {"G17", "G18", "G19"}, } # Track codes found before movement commands codes_before_movement = set() for original_line_number, line in lines_with_numbers: tokens = line.split() # Check if movement commands are encountered if not movement_seen and any(cmd in tokens for cmd in {"G00", "G01", "G02", "G03"}): movement_seen = True if not movement_seen: # Collect required G-codes found before movement codes_before_movement.update(tokens) else: # After movement commands have been seen, check for critical G-codes for token in tokens: for category, codes in critical_gcodes.items(): if token in codes: issues.append((original_line_number, f"(Warning) {token} appears after movement commands. Ensure this change is intentional -> {line.strip()}")) # Check for missing required G-codes before movement commands missing_groups = [] for category, codes in required_groups.items(): if not any(code in codes_before_movement for code in codes): missing_codes = "/".join(sorted(codes)) missing_groups.append(f"({category}) {missing_codes}") if missing_groups: first_movement_line = next( (line_num for line_num, line in lines_with_numbers if any(cmd in line for cmd in {"G00", "G01", "G02", "G03"})), 1 ) issues.append((first_movement_line, f"(Error) Missing required G-codes before first movement: {', '.join(missing_groups)}")) return issues def check_end_gcode(lines_with_numbers): """ Checks that M30 is the last G-code command. Allows blank lines or '%' symbols after M30. """ found_m30 = False # Collect errors with line numbers errors = [] for idx, (original_line_number, line) in enumerate(lines_with_numbers): if not line.strip() or line.strip() == "%": continue # Skip empty lines or lines with only '%' if "M30" in line: if found_m30: errors.append((original_line_number, "(Error) M30 must be the last G-code command in the G-code.")) found_m30 = True continue # Continue to check if any G-code commands appear after M30 # After M30, no other G-code commands should appear if found_m30 and gcode_pattern.search(line): errors.append((original_line_number, f"(Error) No G-code commands should appear after M30. Found '{line.strip()}'.")) if not found_m30: if lines_with_numbers: last_line_number = lines_with_numbers[-1][0] else: last_line_number = 1 errors.append((last_line_number, "(Error) M30 is missing from the G-code.")) return errors def check_spindle(lines_with_numbers): """ Checks spindle-related issues in the G-code. """ issues = [] spindle_on = False spindle_started = False for idx, (original_line_number, line) in enumerate(lines_with_numbers): # Skip processing lines that are empty or contain only '%' if not line.strip() or line.strip() == "%": continue tokens = line.split() # Check for valid G-code commands if not gcode_pattern.search(line): issues.append((original_line_number, f"(Error) Invalid G-code command or syntax error -> {line.strip()}")) # Check for spindle on if "M03" in tokens or "M04" in tokens: # Check if spindle is already on if spindle_on: issues.append((original_line_number, "(Warning) Spindle is already on.")) # Check if spindle speed is specified with 'S' command s_value_present = any(token.startswith("S") for token in tokens) if not s_value_present: issues.append((original_line_number, "(Error) Spindle speed (S value) is missing when turning on the spindle with M03/M04.")) spindle_on = True spindle_started = True # Check for spindle off if "M05" in tokens: spindle_on = False # Check if movement commands are given without spindle on if any(cmd in tokens for cmd in interpolation_commands): if not spindle_on: issues.append((original_line_number, f"(Error) Move command without spindle on -> {line.strip()}")) # Check if spindle was turned off before M30 if spindle_on: last_line_number = lines_with_numbers[-1][0] issues.append((last_line_number, "(Error) Spindle was not turned off (M05) before the end of the program.")) # Check if spindle was never turned on if not spindle_started: issues.append((0, "(Error) Spindle was never turned on in the G-code.")) return issues def check_feed_rate(lines_with_numbers): """ Checks feed rate related issues in the G-code. """ issues = [] last_feed_rate = None interpolation_command_seen = False for idx, (original_line_number, line) in enumerate(lines_with_numbers): # Skip processing lines that are empty or contain only '%' if not line.strip() or line.strip() == "%": continue tokens = line.split() commands = set(tokens) feed_rates = [token for token in tokens if token.startswith("F")] # Check if feed rate is beside non-interpolation commands if feed_rates and not any(cmd in interpolation_commands for cmd in commands): issues.append((original_line_number, f"(Warning) Feed rate specified without interpolation command -> {line.strip()}")) # Check for interpolation commands if any(cmd in commands for cmd in interpolation_commands): if not interpolation_command_seen: interpolation_command_seen = True if not feed_rates and last_feed_rate is None: issues.append((original_line_number, f"(Error) First interpolation command must have a feed rate -> {line.strip()}")) else: # Set initial feed rate if feed_rates: last_feed_rate = feed_rates[-1] else: # Check if feed rate is specified if feed_rates: current_feed_rate = feed_rates[-1] if current_feed_rate == last_feed_rate: issues.append((original_line_number, f"(Warning) Feed rate {current_feed_rate} is already set; no need to specify again.")) else: last_feed_rate = current_feed_rate return issues def check_depth_of_cut(lines_with_numbers, depth_max=0.1): """ Checks that all cutting moves on the Z-axis have a uniform depth and do not exceed the maximum depth. """ getcontext().prec = 6 # Set precision as needed depth_max = Decimal(str(depth_max)) issues = [] positioning_mode = "G90" # Default to absolute positioning current_z = Decimal('0.0') depths = set() z_negative_seen = False for idx, (original_line_number, line) in enumerate(lines_with_numbers): # Skip processing lines that are empty or contain only '%' if not line.strip() or line.strip() == "%": continue tokens = line.split() if "G90" in tokens: positioning_mode = "G90" elif "G91" in tokens: positioning_mode = "G91" if any(cmd in tokens for cmd in interpolation_commands.union(movement_commands)): z_values = [token for token in tokens if token.startswith("Z")] if z_values: try: z_value = Decimal(z_values[-1][1:]) except (ValueError, decimal.InvalidOperation): issues.append((original_line_number, f"(Error) Invalid Z value -> {line.strip()}")) continue if positioning_mode == "G90": new_z = z_value elif positioning_mode == "G91": new_z = current_z + z_value if new_z < Decimal('0.0'): z_negative_seen = True depth = abs(new_z) depth = depth.quantize(Decimal('0.0001')).normalize() # Round and remove trailing zeros depths.add(depth) if depth > depth_max: issues.append((original_line_number, f"(Error) Depth of cut {depth} exceeds maximum allowed depth of {depth_max.normalize()} -> {line.strip()}")) current_z = new_z if z_negative_seen: if len(depths) > 1: depth_values = ', '.join(str(d.normalize()) for d in sorted(depths)) issues.append((0, f"(Warning) Inconsistent depths of cut detected: {depth_values}")) else: issues.append((0, "(Error) No cutting moves detected on the Z-axis.")) return issues def check_interpolation_depth(lines_with_numbers): """ Checks that all interpolation commands moving in X or Y are executed at a negative Z depth (i.e., cutting). Does not report errors for interpolation commands used for plunging or retracting (Z-axis movements only). """ getcontext().prec = 6 # Set precision as needed issues = [] positioning_mode = "G90" # Default to absolute positioning current_z = Decimal('0.0') for idx, (original_line_number, line) in enumerate(lines_with_numbers): # Skip processing lines that are empty or contain only '%' if not line.strip() or line.strip() == "%": continue tokens = line.split() # Update positioning mode if G90 or G91 is found if "G90" in tokens: positioning_mode = "G90" elif "G91" in tokens: positioning_mode = "G91" # Check for Z-axis movement z_values = [token for token in tokens if token.startswith("Z")] if z_values: try: z_value = Decimal(z_values[-1][1:]) except (ValueError, decimal.InvalidOperation): issues.append((original_line_number, f"(Error) Invalid Z value -> {line.strip()}")) continue # Calculate the new Z position based on positioning mode if positioning_mode == "G90": current_z = z_value elif positioning_mode == "G91": current_z += z_value # Check for interpolation commands if any(cmd in tokens for cmd in interpolation_commands): # Check if the command includes X or Y movement has_xy_movement = any(token.startswith(('X', 'Y')) for token in tokens) if has_xy_movement and current_z >= Decimal('0.0'): issues.append((original_line_number, f"(Warning) Interpolation command with XY movement executed without cutting depth (Z={current_z}) -> {line.strip()}")) return issues def check_plunge_retract_moves(lines_with_numbers): """ Checks that plunging and retracting moves along the Z-axis use G01 instead of G00. Reports an error if G00 is used for Z-axis movements to Z positions less than or equal to zero. """ issues = [] positioning_mode = "G90" # Default to absolute positioning current_z = None # Keep track of the current Z position for idx, (original_line_number, line) in enumerate(lines_with_numbers): # Skip processing lines that are empty or contain only '%' if not line.strip() or line.strip() == "%": continue tokens = line.split() # Update positioning mode if G90 or G91 is found if "G90" in tokens: positioning_mode = "G90" elif "G91" in tokens: positioning_mode = "G91" # Check for Z-axis movement z_values = [token for token in tokens if token.startswith("Z")] if z_values: try: z_value = Decimal(z_values[-1][1:]) except (ValueError, decimal.InvalidOperation): issues.append((original_line_number, f"(Error) Invalid Z value -> {line.strip()}")) continue # Calculate the new Z position based on positioning mode if current_z is None: current_z = z_value else: if positioning_mode == "G90": current_z = z_value elif positioning_mode == "G91": current_z += z_value # Check for G00 commands moving to Z ≤ 0 # Check for G00 commands moving to Z ≤ 0 if "G00" in tokens and current_z <= Decimal('0.0'): issues.append((original_line_number, f"(Error) G00 used for plunging to Z={current_z}. Use G01 to safely approach the workpiece -> {line.strip()}")) return issues def run_checks(gcode, depth_max=0.1): """ Runs all checks and returns a tuple containing lists of errors and warnings. """ errors = [] warnings = [] # Preprocess G-code to remove comments and get cleaned lines with original line numbers lines_with_numbers = preprocess_gcode(gcode) # Collect issues from all checks required_gcode_issues = check_required_gcodes(lines_with_numbers) required_gcode_position_issues = check_required_gcodes_position(lines_with_numbers) spindle_issues = check_spindle(lines_with_numbers) feed_rate_issues = check_feed_rate(lines_with_numbers) depth_issues = check_depth_of_cut(lines_with_numbers, depth_max) end_gcode_issues = check_end_gcode(lines_with_numbers) interpolation_depth_issues = check_interpolation_depth(lines_with_numbers) plunge_retract_issues = check_plunge_retract_moves(lines_with_numbers) # Combine all issues all_issues = ( required_gcode_issues + required_gcode_position_issues + spindle_issues + feed_rate_issues + depth_issues + end_gcode_issues + interpolation_depth_issues + plunge_retract_issues ) # Separate issues into errors and warnings for line_num, message in all_issues: if "(Error)" in message: errors.append((line_num, message)) elif "(Warning)" in message: warnings.append((line_num, message)) # Sort issues by line number errors.sort(key=lambda x: x[0]) warnings.sort(key=lambda x: x[0]) return errors, warnings if __name__ == "__main__": # Example usage gcode_sample = """ % G21 G90 G17 G54 G00 X0 Y0 Z5.0 M03 S1000 G01 Z-0.1 F100 ; Plunge using rapid movement (should be G01) G54 G01 Z-0.1 G01 X10 Y10 G01 X20 Y20 G00 Z5.0 ; Retract using rapid movement (allowed since Z > 0) M05 M30 % """ depth_max = 0.1 # Set the maximum allowed depth of cut errors, warnings = run_checks(gcode_sample, depth_max) # Prepare the output as a string output_lines = [] if errors or warnings: output_lines.append("Issues found in G-code:") for line_num, message in errors + warnings: if line_num > 0: output_lines.append(f"Line {line_num}: {message}") else: output_lines.append(message) print('\n'.join(output_lines)) else: print("Your G-code looks good!")