Source code for gcode_reader.preprocess

"""
Pre-processes lines of G-code programs to a consistent key-value format.

Terminology Definitions:
- Line (Block): The RS-274/NGC language is based on lines of code. Each line (block) may include commands to a machining center to do several different things. Lines of code may be collected in a file to make a program.
- Word: A letter other than N followed by a real value. A real value is some collection of characters that can be processed to come up with a number. A real value may be an explicit number (such as 341 or -0.8807), a parameter value, an expression, or a unary operation value. Definitions of these follow immediately. Processing characters to come up with a number is called “evaluating”. An explicit number evaluates to itself.
- Parameter: A key-value pair
- Expression: A combination of parameters, operators, and values that can be evaluated to produce a result. Expressions can be used to calculate values dynamically based on other variables or conditions.

References:
- https://fabricesalvaire.github.io/pythonic-gcode-machine/gcode-reference/rs-274/index.html#format-of-a-line
- https://www.nist.gov/publications/nist-rs274ngc-interpreter-version-3

"""

import logging
import re
from numpy import cos, tan, sin, arccos, arctan, abs, deg2rad, sqrt, round, concatenate
from . import syntax

# Cache compiled regex patterns to avoid recompilation overhead
_compiled_patterns = {}
# Per-flavor stripping spec: id(flavor) -> list of (compiled_re_or_list, repl)
_strip_spec_cache: dict = {}
# Per-flavor param spec: id(flavor) -> (param_re, preset_re_or_None)
_param_spec_cache: dict = {}
# Per-flavor decl spec: id(flavor) -> (param_line_re_or_None, param_decl_re_or_None, has_indicator)
_decl_spec_cache: dict = {}
# Per-flavor fn-expr fast-path: id(flavor) -> bool (True = safe to skip when no brackets in line)
_fn_expr_bracket_fast_path: dict = {}


def _get_compiled(pattern):
    """Return compiled regex or list of compiled regexes for a given pattern spec.

    The input may be None, a string, a compiled re.Pattern, or a list of strings/patterns.
    Compiled patterns are cached in module-level dict to avoid repeated compile cost.
    """
    if pattern is None:
        return None
    if isinstance(pattern, re.Pattern):
        return pattern
    if isinstance(pattern, list):
        compiled = []
        for p in pattern:
            if isinstance(p, re.Pattern):
                compiled.append(p)
            else:
                if p not in _compiled_patterns:
                    _compiled_patterns[p] = re.compile(p)
                compiled.append(_compiled_patterns[p])
        return compiled
    else:
        if pattern not in _compiled_patterns:
            _compiled_patterns[pattern] = re.compile(pattern)
        return _compiled_patterns[pattern]


def _get_strip_spec(flavor: dict):
    """Return (and cache) the list of (compiled_re, replacement) pairs used for stripping."""
    flavor_id = id(flavor)
    if flavor_id in _strip_spec_cache:
        return _strip_spec_cache[flavor_id]
    spec = []
    for key in ("comment_re", "word_re", "function_expression_re"):
        pattern = flavor.get(key)
        if pattern is None:
            continue
        compiled = _get_compiled(pattern)
        if isinstance(compiled, list):
            for compiled_re in compiled:
                repl = replacement_function if compiled_re.groups >= 3 else ""
                spec.append((compiled_re, repl))
        else:
            repl = replacement_function if compiled.groups >= 3 else ""
            spec.append((compiled, repl))
    _strip_spec_cache[flavor_id] = spec
    return spec


[docs] def line_is_modal_group(line: str, flavor: dict = syntax.flavors["default"]): """Determines if G-code line is a modal group. Modal commands are arranged in sets such as "G7 G18 G19" Args: line (str): Un-processed line (block) from a G-code program flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"]. Returns: (bool, list): True if line is modal group, modal group """ cmd_re = _get_compiled(flavor["command_re"]) modal_group = cmd_re.findall(line) if cmd_re is not None else [] if len(modal_group) <= 1: return False, None return True, modal_group
[docs] def line_declares_parameters( line: str, named_parameters: dict = {}, flavor: dict = syntax.flavors["default"] ): """Uses syntax definition to identify declaration of named parameters in line. Args: line (str): line (block) from a G-code program named_parameters (dict): Named parameter definition flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"]. Returns: (bool, dict): Existence of parameter declaration, dictionary of parameters """ # If gcode syntax does not support parameters then ignore if not flavor["parameter_declare_re"] or not flavor["parameter_re"]: return False, named_parameters flavor_id = id(flavor) if flavor_id not in _decl_spec_cache: has_indicator = "parameter_line_indicator" in flavor param_line_indicator_re = ( _get_compiled(flavor["parameter_line_indicator"]) if has_indicator else None ) param_declare_re = _get_compiled(flavor["parameter_declare_re"]) _decl_spec_cache[flavor_id] = ( param_line_indicator_re, param_declare_re, has_indicator, ) param_line_re, param_decl_re, has_indicator = _decl_spec_cache[flavor_id] if has_indicator: if not (param_line_re.search(line) if param_line_re is not None else None): return False, named_parameters parameters = param_decl_re.findall(line) if param_decl_re is not None else [] if len(parameters) == 0: return False, named_parameters for key, val in parameters: named_parameters[key] = eval(val) return True, named_parameters
[docs] def line_insert_parameters( line: str, named_parameters: dict = {}, flavor: dict = syntax.flavors["default"] ): """Uses syntax definition to identify named parameters in line. Replaces with values Args: line (str): line (block) from a G-code program named_parameters (dict): Named parameter definition flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"]. Returns: str: Modified line """ # if the gcode syntax does not support parameters, ignore if not flavor["parameter_re"]: return line # Cached per-flavor: param_re and optional preset_re (CEAD only) flavor_id = id(flavor) if flavor_id not in _param_spec_cache: param_re = _get_compiled(flavor["parameter_re"]) preset_re = ( _get_compiled(r"(PRESETON\(.*\))") if flavor is syntax.flavors["cead"] else None ) _param_spec_cache[flavor_id] = (param_re, preset_re) param_re, preset_re = _param_spec_cache[flavor_id] if param_re is None: return line # Quick check: skip entirely when no parameters are declared, or none appear in the line. # Skip True-valued placeholder entries (added below to flag undeclared identifiers — they're # not real parameters and including them in the substring check causes false hits, e.g. a # placeholder "EL" from a TRANS line matches every "ELX=..." motion line). if not named_parameters: return line for _k, _v in named_parameters.items(): if _v is True: continue if _k in line: break else: return line # Strip all words, comments, and commands from the line stripped_line = line # Pull out the PRESETON in CEAD lines if preset_re is not None: stripped_line = preset_re.sub("", stripped_line) # Strip words, comments, and function expressions using precomputed spec. for compiled_re, repl in _get_strip_spec(flavor): stripped_line = compiled_re.sub(repl, stripped_line) # Get all the parameters all_parameters = param_re.findall(stripped_line) # Parameter substitution - only do string replaces if we found parameters if all_parameters: for parameter in all_parameters: if parameter in named_parameters: clean_parameter = flavor["parameter"].replace("PARAM", parameter) line = line.replace(clean_parameter, str(named_parameters[parameter])) else: named_parameters[parameter] = True return line
# Used to replace functions with just their inner functions
[docs] def replacement_function(match): groups = match.groups() if len(groups) >= 3: return groups[2] else: return ""
[docs] def line_insert_generic_expressions( line: str, flavor: dict = syntax.flavors["default"] ): """Inserts the evaluation of a generic expression into the line, such as [1+1], recursively. Args: line (str): line (block) for a G-code program flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"]. Returns: str: Modified line Reference: https://fabricesalvaire.github.io/pythonic-gcode-machine/gcode-reference/rs-274/index.html#expressions-and-binary-operations """ initial_line = line # if gcode syntax does not support generic expressions, ignore generic_expr_re = flavor.get("generic_expression_re") if not generic_expr_re: return line # Fast path: all expression syntaxes use brackets — skip regex scan if absent if "(" not in line and "[" not in line: return line # Cache compiled comment pattern for reuse comment_re = _get_compiled(flavor.get("comment_re")) # NOTE: Needs to be a while loop to avoid stack overflow. Warning to user if the line isn't processed. Explicit failure probably not warranted here, the line will just be ignored iteration_count = 0 max_iterations = 100 # Prevent infinite loops while iteration_count < max_iterations: iteration_count += 1 original_line = line # Get expressions to process if isinstance(generic_expr_re, list): compiled_list = _get_compiled(generic_expr_re) expressions = [compiled_re.findall(line) for compiled_re in compiled_list] expressions = [item for sublist in expressions for item in sublist] else: compiled = _get_compiled(generic_expr_re) expressions = compiled.findall(line) if compiled is not None else [] if len(expressions) == 0: break # Don't look into expressions inside a comment (only needed if expressions found) comments = comment_re.findall(line) if comment_re is not None else [] no_comments = len(comments) == 0 comment_index = -1 if len(comments) == 1: comment_index = line.find(comments[0]) # Filter out expressions in comments if not no_comments: expressions = [ex for ex in expressions if line.find(ex) <= comment_index] if len(expressions) == 0: break # Process expressions for match in expressions: original_text = match eval_expr = match.replace("[", "(").replace("]", ")").lower() try: result = str(eval(eval_expr)) line = line.replace(original_text, result) except NameError: try: line = line_insert_function_expressions(line, flavor) except: NameError( f'Failed to evaluate expression "{eval_expr}" from text {original_text}' ) except: raise NameError( f'Failed to evaluate expression "{eval_expr}" from text {original_text}' ) # TERMINATION CHECK if line == original_line: logging.warning( f"gcode_reader.preprocess.line_insert_generic_expressions: Generic expression loop failed to fully process the line: {initial_line}. processed to: {line}.Terminating loop." ) break return line
def _cead_function_handler(fn: str, inner_expression: str): if fn == "ic": return None, inner_expression elif fn == "preseton": return None, "0" elif fn == "device": return None, "0" return fn, inner_expression def _ingersoll_function_handler(fn: str, inner_expression: str): if fn == "extruder": return None, f"E{inner_expression}" elif fn == "extruderb": val = int((inner_expression.strip().lower() == "true")) return None, f"E{val}" elif fn == "ca": # inner expression might need evaluation if it includes division or multiplication inner_expression = eval(inner_expression) return None, f"CA{inner_expression}" return fn, inner_expression
[docs] def line_insert_function_expressions(line, flavor: dict = syntax.flavors["default"]): """Inserts the evaluation of a functional expression into the line, such as COS[213], recursively. Args: line (str): line (block) for a G-code program flavor (dict, optional): Syntax definition for line. Defaults to syntax.flavors["default"]. Returns: str: Modified line Reference: https://fabricesalvaire.github.io/pythonic-gcode-machine/gcode-reference/rs-274/index.html#expressions-and-binary-operations """ # if gcode syntax does not support functions then ignore if not flavor["function_expression_re"]: return line # Fast path: if all patterns require literal brackets in the matched text, skip when absent. # Computed once per flavor. A pattern requires brackets iff it contains \( or \[. flavor_id = id(flavor) if flavor_id not in _fn_expr_bracket_fast_path: function_expr_re = flavor["function_expression_re"] patterns = ( function_expr_re if isinstance(function_expr_re, list) else [function_expr_re] ) safe = all( ( (r"\(" in p or r"\[" in p) if isinstance(p, str) else (r"\(" in p.pattern or r"\[" in p.pattern) ) for p in patterns ) _fn_expr_bracket_fast_path[flavor_id] = safe if _fn_expr_bracket_fast_path[flavor_id] and "(" not in line and "[" not in line: return line initial_line = line while True: original_line = line function_expr_re = flavor.get("function_expression_re") if function_expr_re is None: functional_expressions = [] elif isinstance(function_expr_re, list): compiled_list = _get_compiled(function_expr_re) functional_expressions = [ compiled_re.findall(line) for compiled_re in compiled_list ] functional_expressions = [ item for sublist in functional_expressions for item in sublist ] else: compiled = _get_compiled(function_expr_re) functional_expressions = ( compiled.findall(line) if compiled is not None else [] ) if len(functional_expressions) == 0: return line for func in functional_expressions: text, fn, inner_expression = func fn = fn.lower() # Must ensure lowercase if flavor == syntax.flavors["cead"]: # Check these before recursion because the "PRESETON" has a comma in it that causes infinite recursion fn, inner_expression = _cead_function_handler(fn, inner_expression) elif flavor == syntax.flavors["ingersoll"]: fn, inner_expression = _ingersoll_function_handler(fn, inner_expression) # Inner expression might be an expression itself inner_expression = line_insert_generic_expressions(inner_expression, flavor) if fn is None: line = line.replace( text, inner_expression, 1 ) # NOTE: count=1 prevents the line from replacing all occurances, restricts to only the first continue # Typically inner expression is in degrees. However, need to convert to radians # NOTE: This assumes radians. What happens if its degrees? if fn in ["cos", "acos", "sin", "asin", "tan", "atan"]: inner_expression = f"deg2rad({inner_expression})" eval_expression = f"{fn}({inner_expression})" try: val = eval(eval_expression) line = line.replace(text, str(val)) except: raise ValueError( f'Failed to evaluate expression "{eval_expression}" from text {text}' ) # TERMINATION CHECK if line == original_line: logging.warning( f"gcode_reader.preprocess.line_insert_function_expressions: Function expression loop failed to fully process the line: {initial_line}. processed to: {line}.Terminating loop." ) break return line