G-code_Programming_Assistant / python_gcode_checker.py
nicoaspra
update checker: add check_required_gcodes_position
c77aacd
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!")