From ec6ebb2870c30bf5c2b126196f6bcf23a2d6b733 Mon Sep 17 00:00:00 2001 From: Peter Boin Date: Sat, 8 Jul 2017 22:18:58 +1000 Subject: [PATCH] machine processing underway --- pygcode/block.py | 37 +++-- pygcode/comment.py | 6 + pygcode/file.py | 21 +-- pygcode/gcodes.py | 144 +++++++++++++++--- pygcode/machine.py | 331 +++++++++++++++++++++++++++++++++++++----- pygcode/words.py | 17 ++- tests/test_file.py | 24 +-- tests/test_gcodes.py | 12 +- tests/test_line.py | 8 +- tests/test_machine.py | 122 ++++++++++++++++ tests/test_words.py | 19 +-- tests/testutils.py | 25 ++++ 12 files changed, 661 insertions(+), 105 deletions(-) create mode 100644 tests/test_machine.py create mode 100644 tests/testutils.py diff --git a/pygcode/block.py b/pygcode/block.py index 2f456ba..afc267d 100644 --- a/pygcode/block.py +++ b/pygcode/block.py @@ -1,39 +1,50 @@ import re -from .words import iter_words, WORD_MAP +from .words import text2words, WORD_MAP from .gcodes import words2gcodes class Block(object): """GCode block (effectively any gcode file line that defines any )""" - def __init__(self, text): + def __init__(self, text=None, verify=True): """ Block Constructor :param A-Z: gcode parameter values :param comment: comment text """ - self._raw_text = text # unaltered block content (before alteration) + self._raw_text = None + self._text = None + self.words = [] + self.gcodes = [] + self.modal_params = [] # clean up block string - text = re.sub(r'(^\s+|\s+$)', '', text) # remove whitespace padding - text = re.sub(r'\s+', ' ', text) # remove duplicate whitespace with ' ' + if text: + self._raw_text = text # unaltered block content (before alteration) + text = re.sub(r'(^\s+|\s+$)', '', text) # remove whitespace padding + text = re.sub(r'\s+', ' ', text) # remove duplicate whitespace with ' ' + self._text = text # cleaned up block content - self.text = text + # Get words from text, and group into gcodes + self.words = list(text2words(self._text)) + (self.gcodes, self.modal_params) = words2gcodes(self.words) - self.words = list(iter_words(self.text)) - (self.gcodes, self.modal_params) = words2gcodes(self.words) + # Verification + if verify: + self._assert_gcodes() - self._assert_gcodes() - - # TODO: gcode verification - # - gcodes in the same modal_group raises exception + @property + def text(self): + if self._text: + return self._text + return str(self) def _assert_gcodes(self): modal_groups = set() code_words = set() for gc in self.gcodes: - + # Assert all gcodes are not repeated in the same block if gc.word in code_words: raise AssertionError("%s cannot be in the same block" % ([ diff --git a/pygcode/comment.py b/pygcode/comment.py index dfae3ed..5e9b170 100644 --- a/pygcode/comment.py +++ b/pygcode/comment.py @@ -7,6 +7,12 @@ class CommentBase(object): def __init__(self, text): self.text = text + def __repr__(self): + return "<{class_name}: '{comment}'>".format( + class_name=self.__class__.__name__, + comment=str(self), + ) + class CommentSemicolon(CommentBase): "Comments of the format: 'G00 X1 Y2 ; something profound'" diff --git a/pygcode/file.py b/pygcode/file.py index 44fab3f..fa9f395 100644 --- a/pygcode/file.py +++ b/pygcode/file.py @@ -14,12 +14,15 @@ class GCodeFile(object): self.lines.append(line) -def parse(filename): - # FIXME: should be an iterator, and also not terrible - file = GCodeFile() - with open(filename, 'r') as fh: - for line in fh.readlines(): - line_obj = Line(line) - # FIXME: don't dump entire file into RAM; change to generator model - file.append(line_obj) - return file +class GCodeParser(object): + """Parse a gocde file""" + def __init__(self, filename): + self.filename = filename + self._fh = open(filename, 'r') + + def iterlines(self): + for line in self._fh.readlines(): + yield Line(line) + + def close(self): + self._fh.close() diff --git a/pygcode/gcodes.py b/pygcode/gcodes.py index cfe49e4..4e8b116 100644 --- a/pygcode/gcodes.py +++ b/pygcode/gcodes.py @@ -40,7 +40,7 @@ from .words import Word # # Table 5. G-Code Modal Groups # MODAL GROUP MEANING MEMBER WORDS -# Non-modal codes (Group 0) G4, G10 G28, G30, G53 G92, G92.1, G92.2, G92.3, +# Non-modal codes (Group 0) G4, G10 G28, G30, G53, G92, G92.1, G92.2, G92.3, # Motion (Group 1) G0, G1, G2, G3, G33, G38.x, G73, G76, G80, G81 # G82, G83, G84, G85, G86, G87, G88,G89 # Plane selection (Group 2) G17, G18, G19, G17.1, G18.1, G19.1 @@ -132,6 +132,12 @@ MODAL_GROUP_MAP = { # 240: Perform motion (G0 to G3, G33, G38.x, G73, G76, G80 to G89), as modified (possibly) by G53. # 250: Stop (M0, M1, M2, M30, M60). +class GCodeEffect(object): + """Effect a gcode has on a machine upon processing""" + def __init__(self, **kwargs): + self.mode = {} + self.dt = 0.0 + class GCode(object): # Defining Word @@ -149,6 +155,10 @@ class GCode(object): exec_order = 999 # if not otherwise specified, run last def __init__(self, word, *params): + """ + :param word: Word instance defining gcode (eg: Word('G0') for rapid movement) + :param params: list of Word instances (eg: Word('X-1.2') as x-coordinate) + """ assert isinstance(word, Word), "invalid gcode word %r" % code_word self.word = word self.params = {} @@ -188,6 +198,10 @@ class GCode(object): return self.exec_order < other.exec_order def add_parameter(self, word): + """ + Add given word as a parameter for this gcode + :param word: Word instance + """ assert isinstance(word, Word), "invalid parameter class: %r" % word assert word.letter in self.param_letters, "invalid parameter for %s: %s" % (self.__class__.__name__, str(word)) assert word.letter not in self.params, "parameter defined twice: %s -> %s" % (self.params[word.letter], word) @@ -217,6 +231,44 @@ class GCode(object): if l in self.modal_param_letters ]) + def get_param_dict(self, letters=None): + """ + Get gcode parameters as a dict + gcode parameter like "X3.1, Y-2" would return {'X': 3.1, 'Y': -2} + :param letters: iterable whitelist of letters to include as dict keys + :return: dict of gcode parameters' (letter, value) pairs + """ + return dict( + (w.letter, w.value) for w in self.params.values() + if (letters is None) or (w.letter in letters) + ) + + # Process GCode + def process(self, machine): + """ + Process a GCode on the given Machine + :param machine: Machine instance, to change state + :return: GCodeEffect instance; effect the gcode just had on machine + """ + from .machine import Machine # importing high-level state + assert isinstance(machine, Machine), "invalid parameter" + effect = GCodeEffect() + + # Set mode + self._process_mode(machine) + + # GCode specific + self._process(machine) + + def _process_mode(self, machine): + """Set machine's state""" + machine.set_mode(self) + + def _process(self, machine): + """Process this GCode (to be overridden)""" + pass + + # ======================= Motion ======================= # (X Y Z A B C U V W apply to all motions) @@ -238,10 +290,17 @@ class GCodeMotion(GCode): modal_group = MODAL_GROUP_MAP['motion'] exec_order = 241 + def _process(self, machine): + machine.move_to(**self.get_param_dict(letters=machine.axes)) + + class GCodeRapidMove(GCodeMotion): """G0: Rapid Move""" word_key = Word('G', 0) + def _process(self, machine): + machine.move_to(rapid=True, **self.get_param_dict(letters=machine.axes)) + class GCodeLinearMove(GCodeMotion): """G1: Linear Move""" @@ -334,6 +393,7 @@ class GCodeCannedCycle(GCode): modal_group = MODAL_GROUP_MAP['motion'] exec_order = 241 + class GCodeDrillingCycle(GCodeCannedCycle): """G81: Drilling Cycle""" param_letters = GCodeCannedCycle.param_letters | set('RLP') @@ -600,11 +660,13 @@ class GCodeUnit(GCode): class GCodeUseInches(GCodeUnit): """G20: use inches for length units""" word_key = Word('G', 20) + unit_id = 0 class GCodeUseMillimeters(GCodeUnit): """G21: use millimeters for length units""" word_key = Word('G', 21) + unit_id = 1 # ======================= Plane Selection ======================= @@ -815,22 +877,64 @@ class GCodeFeedStop(GCodeOtherModal): class GCodeSelectCoordinateSystem(GCodeOtherModal): - """ - G54 - select coordinate system 1 - G55 - select coordinate system 2 - G56 - select coordinate system 3 - G57 - select coordinate system 4 - G58 - select coordinate system 5 - G59 - select coordinate system 6 - G59.1 - select coordinate system 7 - G59.2 - select coordinate system 8 - G59.3 - select coordinate system 9 - """ - @classmethod - def word_matches(cls, w): - return (w.letter == 'G') and (w.value in [54, 55, 56, 57, 58, 59, 59.1, 59.2, 59.3]) + """Select Coordinate System""" modal_group = MODAL_GROUP_MAP['coordinate_system'] exec_order = 190 + coord_system_id = None # overridden in inheriting classes + + +class GCodeSelectCoordinateSystem1(GCodeSelectCoordinateSystem): + """Select Coordinate System 1""" + word_key = Word('G', 54) + coord_system_id = 1 + + +class GCodeSelectCoordinateSystem2(GCodeSelectCoordinateSystem): + """Select Coordinate System 2""" + word_key = Word('G', 55) + coord_system_id = 2 + + +class GCodeSelectCoordinateSystem3(GCodeSelectCoordinateSystem): + """Select Coordinate System 3""" + word_key = Word('G', 56) + coord_system_id = 3 + + +class GCodeSelectCoordinateSystem4(GCodeSelectCoordinateSystem): + """Select Coordinate System 4""" + word_key = Word('G', 57) + coord_system_id = 4 + + +class GCodeSelectCoordinateSystem5(GCodeSelectCoordinateSystem): + """Select Coordinate System 5""" + word_key = Word('G', 58) + coord_system_id = 5 + + +class GCodeSelectCoordinateSystem6(GCodeSelectCoordinateSystem): + """Select Coordinate System 6""" + word_key = Word('G', 59) + coord_system_id = 6 + + +class GCodeSelectCoordinateSystem7(GCodeSelectCoordinateSystem): + """Select Coordinate System 7""" + coord_system_id = 7 + word_key = Word('G', 59.1) + + +class GCodeSelectCoordinateSystem8(GCodeSelectCoordinateSystem): + """Select Coordinate System 8""" + word_key = Word('G', 59.2) + coord_system_id = 8 + + +class GCodeSelectCoordinateSystem9(GCodeSelectCoordinateSystem): + """Select Coordinate System 9""" + word_key = Word('G', 59.3) + coord_system_id = 9 # ======================= Flow-control Codes ======================= @@ -981,6 +1085,8 @@ class GCodeResetCoordSystemOffset(GCodeNonModal): return (w.letter == 'G') and (w.value in [92.1, 92.2]) exec_order = 230 + # TODO: machine.state.offset *= 0 + class GCodeRestoreCoordSystemOffset(GCodeNonModal): """G92.3: Restore Coordinate System Offset""" @@ -1009,7 +1115,7 @@ def _subclasses_level(root_class, recursion_level=0): :param """ yield (root_class, recursion_level) - for cls in root_class.__subclasses__(): + for cls in sorted(root_class.__subclasses__(), key=lambda c: c.__name__): for (sub, level) in _subclasses_level(cls, recursion_level+1): yield (sub, level) @@ -1028,7 +1134,11 @@ def _gcode_class_infostr(base_class=GCode): """ info_str = "" for (cls, level) in _subclasses_level(base_class): - info_str += "{indent}- {name}: {description}\n".format( + word_str = '' + if cls.word_key: + word_str = cls.word_key.clean_str + info_str += "{word} {indent}- {name}: {description}\n".format( + word="%-5s" % word_str, indent=(level * " "), name=cls.__name__, description=cls.__doc__ or "", diff --git a/pygcode/machine.py b/pygcode/machine.py index 53afa0a..61a1b5c 100644 --- a/pygcode/machine.py +++ b/pygcode/machine.py @@ -1,12 +1,211 @@ - +from copy import copy, deepcopy from collections import defaultdict -from .gcodes import MODAL_GROUP_MAP, GCode +from .gcodes import ( + MODAL_GROUP_MAP, GCode, + # Modal GCodes + GCodeIncrementalDistanceMode, + GCodeUseInches, GCodeUseMillimeters, +) +from .block import Block from .line import Line +from .words import Word + + +UNIT_IMPERIAL = GCodeUseInches.unit_id # G20 +UNIT_METRIC = GCodeUseMillimeters.unit_id # G21 +UNIT_MAP = { + UNIT_IMPERIAL: { + 'name': 'inches', + 'conversion_factor': { UNIT_METRIC: 25.4 }, + }, + UNIT_METRIC: { + 'name': 'millimeters', + 'conversion_factor': { UNIT_IMPERIAL: 1. / 25.4 }, + }, +} + + +class Position(object): + default_axes = 'XYZABCUVW' + default_unit = UNIT_METRIC + POSSIBLE_AXES = set('XYZABCUVW') + + def __init__(self, axes=None, **kwargs): + # Set axes (note: usage in __getattr__ and __setattr__) + if axes is None: + axes = self.__class__.default_axes + else: + invalid_axes = set(axes) - self.POSSIBLE_AXES + assert not invalid_axes, "invalid axes proposed %s" % invalid_axes + self.__dict__['axes'] = set(axes) & self.POSSIBLE_AXES + + # Unit + self._unit = kwargs.pop('unit', self.default_unit) + + # Initial Values + self._value = defaultdict(lambda: 0.0, dict((k, 0.0) for k in self.axes)) + self._value.update(kwargs) + + # Attributes Get/Set + def __getattr__(self, key): + if key in self.axes: + return self._value[key] + + raise AttributeError("'{cls}' object has no attribute '{key}'".format( + cls=self.__class__.__name__, + key=key + )) + + def __setattr__(self, key, value): + if key in self.axes: + self._value[key] = value + elif key in self.POSSIBLE_AXES: + raise AssertionError("'%s' axis is not defined to be set" % key) + else: + self.__dict__[key] = value + + # Copy + def __copy__(self): + return self.__class__(axes=copy(self.axes), unit=self._unit, **self.values) + + # Equality + def __eq__(self, other): + if self.axes ^ other.axes: + return False + else: + if self._unit == other._unit: + return self._value == other._value + else: + x = copy(other) + x.set_unit(self._unit) + return self._value == x._value + + def __ne__(self, other): + return not self.__eq__(other) + + # Arithmetic + def __add__(self, other): + assert not (self.axes ^ other.axes), "axes: %r != %r" % (self.axes, other.axes) + new_obj = copy(self) + for k in new_obj._value: + new_obj._value[k] += other._value[k] + return new_obj + + def __sub__(self, other): + assert not (other.axes - self.axes), "for a - b: axes in b, that are not in a: %r" % (other.axes - self.axes) + new_obj = copy(self) + for k in other._value: + new_obj._value[k] -= other._value[k] + return new_obj + + def __mul__(self, scalar): + new_obj = copy(self) + for k in self._value: + new_obj._value[k] = self._value[k] * scalar + return new_obj + + def __div__(self, scalar): + new_obj = copy(self) + for k in self._value: + new_obj._value[k] = self._value[k] / scalar + return new_obj + + __truediv__ = __div__ # Python 3 division + + # Conversion + def set_unit(self, unit): + if unit == self._unit: + return + factor = UNIT_MAP[self._unit]['conversion_factor'][unit] + for k in [k for (k, v) in self._value.items() if v is not None]: + self._value[k] *= factor + self._unit = unit + + @property + def words(self): + return sorted(Word(k, self._value[k]) for k in self.axes) + + @property + def values(self): + return dict(self._value) + + def __repr__(self): + return "<{class_name}: {coordinates}>".format( + class_name=self.__class__.__name__, + coordinates=' '.join(str(w) for w in self.words) + ) + + +class CoordinateSystem(object): + def __init__(self, axes): + self.offset = Position(axes) + + def __add__(self, other): + if isinstance(other, CoordinateSystem): + pass + + def __repr__(self): + return "<{class_name}: offset={offset}>".format( + class_name=self.__class__.__name__, + offset=repr(self.offset), + ) class State(object): """State of a Machine""" + # LinuxCNC documentation lists parameters for a machine's state: + # http://linuxcnc.org/docs/html/gcode/overview.html#sub:numbered-parameters + # AFAIK: this is everything needed to remember a machine's state that isn't + # handled by modal gcodes. + def __init__(self, axes=None): + # Coordinate Systems + self.coord_systems = {} + for i in range(1, 10): # G54-G59.3 + self.coord_systems[i] = CoordinateSystem(axes) + + self.cur_coord_sys = 1 # default to coord system 1 (G54) + + # Temporary Offset + self.offset = Position(axes) # G92 offset (reset by G92.x) + + # Missing from state (according to LinuxCNC's state variables): + # - G38.2 probe result (Position()) + # - G38 probe result (bool) + # - M66: result (bool) + # - Tool offsets (Position()) + # - Tool info (number, diameter, front angle, back angle, orientation) + + #self.work_offset = defaultdict(lambda: 0.0) + + # TODO: how to manage work offsets? (probs not like the above) + # read up on: + # Coordinate System config: + # - G92: set machine coordinate system value (no movement, effects all coordinate systems) + # - G10 L2: offsets the origin of the axes in the coordinate system specified to the value of the axis word + # - G10 L20: makes the current machine coordinates the coordinate system's offset + # Coordinate System selection: + # - G54-G59: select coordinate system (offsets from machine coordinates set by G10 L2) + + # TODO: Move this class into MachineState + + @property + def coord_sys(self): + """Current equivalent coordinate system, including all """ + if self.cur_coord_sys in self.coord_systems: + return self.coord_systems[self.cur_coord_sys] + return None + + def __repr__(self): + return "<{class_name}: coord_sys[{coord_index}]; offset={offset}>".format( + class_name=self.__class__.__name__, + coord_index=self.cur_coord_sys, + offset=repr(self.offset), + ) + + +class Mode(object): + """Machine's mode""" # State is very forgiving: # Anything possible in a machine's state may be changed & fetched. # For example: x, y, z, a, b, c may all be set & requested. @@ -15,36 +214,29 @@ class State(object): # It is also possible to set an axis to an impossibly large distance. # It is the responsibility of the Machine using this class to be # discerning in these respects. - def __init__(self): - self.axes = defaultdict(lambda: 0.0) # aka: "machine coordinates" - self.work_offset = defaultdict(lambda: 0.0) - # TODO: how to manage work offsets? (probs not like the above) - # read up on: - # - G92: coordinate system offset - # - G54-G59: select coordinate system (offsets from machine coordinates set by G10 L2) - - # TODO: Move this class into MachineState - - -class MachineState(object): - """Machine's state, and mode""" # Default Mode + # for a Grbl controller this can be obtained with the `$G` command, eg: + # > $G + # > [GC:G0 G54 G17 G21 G90 G94 M5 M9 T0 F0 S0] + # ref: https://github.com/gnea/grbl/wiki/Grbl-v1.1-Commands#g---view-gcode-parser-state default_mode = ''' - G17 (plane_selection: X/Y plane) - G90 (distance: absolute position. ie: not "turtle" mode) - G91.1 (arc_ijk_distance: IJK sets arc center vertex relative to current position) - G94 (feed_rate_mode: feed-rate defined in units/min) - G21 (units: mm) - G40 (cutter_diameter_comp: no compensation) - G49 (tool_length_offset: no offset) - G61 (control_mode: exact path mode) - G97 (spindle_speed_mode: RPM Mode) - M0 (stopping: program paused) - M5 (spindle: off) - M9 (coolant: off) - F100 (feed_rate: 100 mm/min) - S1000 (tool: 1000 rpm, when it's switched on) + G0 (movement: rapid) + G17 (plane_selection: X/Y plane) + G90 (distance: absolute position. ie: not "turtle" mode) + G91.1 (arc_ijk_distance: IJK sets arc center vertex relative to current position) + G94 (feed_rate_mode: feed-rate defined in units/min) + G21 (units: mm) + G40 (cutter_diameter_comp: no compensation) + G49 (tool_length_offset: no offset) + G54 (coordinate_system: 1) + G61 (control_mode: exact path mode) + G97 (spindle_speed_mode: RPM Mode) + M5 (spindle: off) + M9 (coolant: off) + F0 (feed_rate: 0) + S0 (spindle_speed: 0) + T0 (tool: 0) ''' # Mode is defined by gcodes set by processed blocks: @@ -56,9 +248,18 @@ class MachineState(object): self.set_mode(*Line(self.default_mode).block.gcodes) def set_mode(self, *gcode_list): + """ + Set machine mode from given gcodes + :param gcode_list: list of GCode instances (given as individual parameters) + :return: dict of form: {: , ...} + """ + modal_gcodes = {} for g in sorted(gcode_list): # sorted by execution order if g.modal_group is not None: self.modal_groups[g.modal_group] = g.modal_copy() + modal_gcodes[g.modal_group] = self.modal_groups[g.modal_group] + # assumption: no 2 gcodes are in the same modal_group + return modal_gcodes def __getattr__(self, key): if key in MODAL_GROUP_MAP: @@ -99,8 +300,34 @@ class MachineState(object): class Machine(object): + """Machine to process gcodes, enforce axis limits, keep track of time, etc""" + + # Class types + MODE_CLASS = Mode + STATE_CLASS = State + + axes = set('XYZ') + def __init__(self): - self.state = MachineState() + self.mode = self.MODE_CLASS() + self.state = self.STATE_CLASS(axes=self.axes) + + # Position type (with default axes the same as this machine) + self.Position = type('Position', (Position,), { + 'default_axes': self.axes, + 'default_unit': self.mode.units.unit_id, + }) + + # Absolute machine position + self.abs_pos = self.Position() + + def set_mode(self, *gcode_list): + self.mode.set_mode(*gcode_list) # passthrough + + # Act on mode changes + coord_sys_mode = self.mode.coordinate_system + if coord_sys_mode: + self.state.cur_coord_sys = coord_sys_mode.coord_system_id def process(self, *gcode_list, **kwargs): """ @@ -108,16 +335,52 @@ class Machine(object): :param gcode_list: list of GCode instances :param modal_params: list of Word instances to be applied to current movement mode """ + # Add modal gcode to list of given gcodes modal_params = kwargs.get('modal_params', []) - - #process_gcodes = + if modal_params: + assert self.mode.motion is not None, "unable to assign modal parameters when no motion mode is set" + (modal_gcodes, unasigned_words) = words2gcodes([self.mode.motion.word] + modal_params) + assert unasigned_words == [], "modal parameters '%s' unknown when in mode: %r" % ( + ' '.join(str(x) for x in unasigned_words), self.mode + ) + gcode_list += modal_gcodes for gcode in sorted(gcode_list): - self.state.set_mode(gcode) # if gcode is not modal, it's ignored + gcode.process(self) # shifts ownership of what happens now to GCode class - gcode.process(self.state) # TODO: gcode instance to change machine's state # Questions to drive design: # - how much time did the command take? # - what was the tool's distance / displacement # - did the tool travel outside machine boundaries? + # Use-cases + # - Transform / rotate coordinate system in given gcode + # - Convert arcs to linear segments (visa versa?) + # - Correct precision errors + # - Crop a file (eg: resume half way through) + + def process_block(self, block): + self.process(*block.gcodes, modal_params=block.modal_params) + + @property + def pos(self): + """Return current position in current coordinate system""" + coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes)) + temp_offset = self.state.offset + return (self.abs_pos - coord_sys_offset) - temp_offset + + @pos.setter + def pos(self, value): + """Set absolute position given current position and coordinate system""" + coord_sys_offset = getattr(self.state.coord_sys, 'offset', Position(axes=self.axes)) + temp_offset = self.state.offset + self.abs_pos = (value + temp_offset) + coord_sys_offset + + # =================== Machine Actions =================== + def move_to(self, rapid=False, **coords): + """Move machine to given position""" + given_position = Position(axes=self.axes, **coords) + if isinstance(self.mode.distance, GCodeIncrementalDistanceMode): + self.pos += given_position + else: # assumed: GCodeAbsoluteDistanceMode + self.pos = given_position diff --git a/pygcode/words.py b/pygcode/words.py index 5ced857..f4ead70 100644 --- a/pygcode/words.py +++ b/pygcode/words.py @@ -281,7 +281,7 @@ class Word(object): return "%s: %s" % (self.letter, WORD_MAP[self.letter]['description']) -def iter_words(block_text): +def text2words(block_text): """ Iterate through block text yielding Word instances :param block_text: text for given block with comments removed @@ -315,8 +315,21 @@ def iter_words(block_text): def str2word(word_str): - words = list(iter_words(word_str)) + words = list(text2words(word_str)) if words: assert len(words) <= 1, "more than one word given" return words[0] return None + + +def words2dict(word_list, limit_word_letters=None): + """ + Represent a list of words as a dict + :param limit_word_letters: iterable containing a white-list of word letters (None allows all) + :return: dict of the form: {: , ... } + """ + # Remember: duplicate word letters cannot be represented as a dict + return dict( + (w.letter, w.value) for w in word_list + if (limit_word_letters is None) or (w.letter in limit_word_letters) + ) diff --git a/tests/test_file.py b/tests/test_file.py index 82a20ee..e678b74 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -4,17 +4,21 @@ import inspect import unittest -# Units Under Test -_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) -sys.path.insert(0, os.path.join(_this_path, '..')) -from pygcode.file import parse, GCodeFile +# Add relative pygcode to path +from testutils import add_pygcode_to_path, str_lines +add_pygcode_to_path() -class FileParseTest(unittest.TestCase): +# Units under test +from pygcode.file import GCodeFile, GCodeParser + +class GCodeParserTest(unittest.TestCase): FILENAME = 'test-files/vertical-slot.ngc' def test_parser(self): - file = parse(self.FILENAME) - self.assertEqual(len(file.lines), 26) - # FIXME: just verifying content visually - #for line in file.lines: - # print(line) + parser = GCodeParser(self.FILENAME) + # count lines + line_count = 0 + for line in parser.iterlines(): + line_count += 1 + self.assertEqual(line_count, 26) + parser.close() diff --git a/tests/test_gcodes.py b/tests/test_gcodes.py index 1ae0490..ef3374a 100644 --- a/tests/test_gcodes.py +++ b/tests/test_gcodes.py @@ -4,9 +4,11 @@ import inspect import re import unittest -# Units Under Test -_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) -sys.path.insert(0, os.path.join(_this_path, '..')) +# Add relative pygcode to path +from testutils import add_pygcode_to_path, str_lines +add_pygcode_to_path() + +# Units under test from pygcode import gcodes from pygcode import words class TestGCodeWordMapping(unittest.TestCase): @@ -60,7 +62,7 @@ class TestGCodeModalGroups(unittest.TestCase): match = re.search(r'^\s*(?P.*)\s*\(Group (?P<group>\d+)\)\s*(?P<words>.*)$', row, re.I) if match: for word_str in re.split(r'\s*,\s*', match.group('words')): - word = list(words.iter_words(word_str))[0] + word = list(words.text2words(word_str))[0] gcode_class = gcodes.word_gcode_class(word) # GCode class found for each word in the table self.assertIsNotNone(gcode_class) @@ -81,7 +83,7 @@ class TestGCodeModalGroups(unittest.TestCase): class TestWordsToGCodes(unittest.TestCase): def test_stuff(self): # FIXME: function name line = 'G1 X82.6892 Y-38.6339 F1500' - word_list = list(words.iter_words(line)) + word_list = list(words.text2words(line)) result = gcodes.words2gcodes(word_list) # result form self.assertIsInstance(result, tuple) diff --git a/tests/test_line.py b/tests/test_line.py index 2ad74a5..24337ca 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -4,9 +4,11 @@ import inspect import unittest -# Units Under Test -_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) -sys.path.insert(0, os.path.join(_this_path, '..')) +# Add relative pygcode to path +from testutils import add_pygcode_to_path, str_lines +add_pygcode_to_path() + +# Units under test from pygcode.line import Line diff --git a/tests/test_machine.py b/tests/test_machine.py new file mode 100644 index 0000000..c77a3f8 --- /dev/null +++ b/tests/test_machine.py @@ -0,0 +1,122 @@ +import unittest + +# Add relative pygcode to path +from testutils import add_pygcode_to_path, str_lines +add_pygcode_to_path() + +# Units under test +from pygcode.machine import Position, Machine +from pygcode.line import Line + + +class PositionTests(unittest.TestCase): + def test_basics(self): + p = Position() + # + + def test_default_axes(self): + p = Position() # no instantiation parameters + # all initialized to zero + for axis in 'XYZABCUVW': + self.assertEqual(getattr(p, axis), 0) + + for axis in 'XYZABCUVW': + # set to 100 + setattr(p, axis, 100) + self.assertEqual(getattr(p, axis), 100) + for inner_axis in set('XYZABCUVW') - {axis}: # no other axis has changed + self.assertEqual(getattr(p, inner_axis), 0), "axis '%s'" % inner_axis + # revert back to zero + setattr(p, axis, 0) + self.assertEqual(getattr(p, axis), 0) + + # Equality + def test_equality(self): + p1 = Position(axes='XYZ', X=1, Y=2) + p2 = Position(axes='XYZ', X=1, Y=2, Z=0) + p3 = Position(axes='XYZ', X=1, Y=2, Z=1000) + p4 = Position(axes='XYZA', X=1, Y=2, Z=0) + + # p1 <--> p2 + self.assertTrue(p1 == p2) + self.assertFalse(p1 != p2) # negative case + + # p2 <--> p3 + self.assertTrue(p2 != p3) + self.assertFalse(p2 == p3) # negative case + + # p2 <--> p4 + self.assertTrue(p2 != p4) + self.assertFalse(p2 == p4) # negative case + + # Arithmetic + def test_arithmetic_add(self): + p1 = Position(axes='XYZ', X=1, Y=2) + p2 = Position(axes='XYZ', Y=10, Z=-20) + self.assertEqual(p1 + p2, Position(axes='XYZ', X=1, Y=12, Z=-20)) + + p3 = Position(axes='XYZA') + with self.assertRaises(AssertionError): + p1 + p3 # mismatched axes + with self.assertRaises(AssertionError): + p3 + p1 # mismatched axes + + def test_arithmetic_sub(self): + p1 = Position(axes='XYZ', X=1, Y=2) + p2 = Position(axes='XYZ', Y=10, Z=-20) + self.assertEqual(p1 - p2, Position(axes='XYZ', X=1, Y=-8, Z=20)) + + p3 = Position(axes='XYZA') + p3 - p1 # fine + with self.assertRaises(AssertionError): + p1 - p3 # mismatched axes + + def test_arithmetic_multiply(self): + p = Position(axes='XYZ', X=2, Y=10) + self.assertEqual(p * 2, Position(axes='XYZ', X=4, Y=20)) + + def test_arithmetic_divide(self): + p = Position(axes='XYZ', X=2, Y=10) + self.assertEqual(p / 2, Position(axes='XYZ', X=1, Y=5)) + + + +class MachineGCodeProcessingTests(unittest.TestCase): + def test_linear_movement(self): + m = Machine() + test_str = '''; move in a 10mm square + F100 M3 S1000 ; 0 + g1 x0 y10 ; 1 + g1 x10 y10 ; 2 + g1 x10 y0 ; 3 + g1 x0 y0 ; 4 + ''' + expected_pos = { + '0': m.Position(), + '1': m.Position(X=0, Y=10), + '2': m.Position(X=10, Y=10), + '3': m.Position(X=10, Y=0), + '4': m.Position(X=0, Y=0), + } + #print("\n%r\n%r" % (m.mode, m.state)) + for line_text in str_lines(test_str): + line = Line(line_text) + if line.block: + #print("\n%s" % line.block) + m.process_block(line.block) + # Assert possition change correct + comment = line.comment.text + if comment in expected_pos: + self.assertEqual(m.pos, expected_pos[comment]) + #print("%r\n%r\npos=%r" % (m.mode, m.state, m.pos)) + + +#m = Machine() +# +#file = GCodeParser('part1.gcode') +#for line in file.iterlines(): +# for (i, gcode) in enumerate(line.block.gcode): +# if isinstance(gcode, GCodeArcMove): +# arc = gcode +# line_params = arc.line_segments(precision=0.0005) +# for diff --git a/tests/test_words.py b/tests/test_words.py index 6dfa959..5152f5d 100644 --- a/tests/test_words.py +++ b/tests/test_words.py @@ -4,23 +4,18 @@ import inspect import unittest -# Units Under Test -_this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) -sys.path.insert(0, os.path.join(_this_path, '..')) +# Add relative pygcode to path +from testutils import add_pygcode_to_path, str_lines +add_pygcode_to_path() + +# Units under test from pygcode import words -#words.iter_words - -class WordTests(unittest.TestCase): - def test_O(self): - pass # TODO - - class WordIterTests(unittest.TestCase): def test_iter1(self): block_str = 'G01 Z-0.5 F100' - w = list(words.iter_words(block_str)) + w = list(words.text2words(block_str)) # word length self.assertEqual(len(w), 3) # word values @@ -30,7 +25,7 @@ class WordIterTests(unittest.TestCase): def test_iter2(self): block_str = 'G02 X10.75 Y47.44 I-0.11 J-1.26 F70' - w = list(words.iter_words(block_str)) + w = list(words.text2words(block_str)) # word length self.assertEqual(len(w), 6) # word values diff --git a/tests/testutils.py b/tests/testutils.py new file mode 100644 index 0000000..f6124aa --- /dev/null +++ b/tests/testutils.py @@ -0,0 +1,25 @@ +import sys +import os +import inspect + +import re + +# Units Under Test +_pygcode_in_path = False +def add_pygcode_to_path(): + global _pygcode_in_path + if not _pygcode_in_path: + # Add pygcode (relative to this test-path) to the system path + _this_path = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) + sys.path.insert(0, os.path.join(_this_path, '..')) + + _pygcode_in_path = True + +add_pygcode_to_path() + + +# String Utilities +def str_lines(text): + """Split given string into lines (ignore blank lines, and automagically strip)""" + for match in re.finditer(r'\s*(?P<content>.*?)\s*\n', text): + yield match.group('content')