diff --git a/.gitignore b/.gitignore index bac875e..436bf2e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.png *.inp *.csv +*.prof # build artifacts __pycache__ @@ -15,7 +16,7 @@ dist/ # output folders from examples and tests /output_benchy_example/* /tests/output/* - +/docs/ # coverage reports .coverage diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 282bb5c..464c7c7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -36,9 +36,37 @@ test-package: path: ./tests/coverage.xml when: always +doc-build: + stage: doc + image: "python:3.13" + needs: [] + rules: + - if: $CI_COMMIT_BRANCH == "main" + before_script: + - apt-get update && apt-get install git-lfs + - git lfs install + - pip install --upgrade pip + script: + - git config --global user.name "${GITLAB_USER_NAME}" + - git config --global user.email "${GITLAB_USER_EMAIL}" + - git remote set-url origin https://gitlab-ci-token:${pyGCD_access_token}@gitlab.kit.edu/kit/fast/lb/collaboration/additive-manufacturing/pygcodedecode.git + - pip install -e .[DOCS] + - pydoc-markdown + - cd docs + - mkdocs gh-deploy --force + artifacts: + untracked: false + paths: + - ./docs/site/ + - ./docs/content/*.md + when: always + expire_in: "30 days" + doc-compile_paper: stage: doc needs: [] + rules: + - when: manual tags: - shell script: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ab8ee5..5cc8b23 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,19 +13,19 @@ repos: - --maxkb=5000 # Black code style formatter - repo: https://github.com/psf/black - rev: 24.10.0 + rev: 25.1.0 hooks: - id: black - id: black-jupyter # isort import sorting - repo: https://github.com/timothycrosley/isort - rev: "5.13.2" + rev: "6.0.1" hooks: - id: isort args: ["--profile", "black"] # Flake8 for linting - repo: https://github.com/pycqa/flake8 - rev: "7.1.1" + rev: "7.1.2" hooks: - id: flake8 additional_dependencies: [flake8-docstrings] @@ -36,6 +36,6 @@ repos: - id: nbstripout # pyupgrade - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + rev: v3.19.1 hooks: - id: pyupgrade diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c74b058 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,10 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + {"name":"Python Debugger: Current File","type":"debugpy","request":"launch","program":"${file}","console":"integratedTerminal"}, + {"name":"Python Debugger: Benchy","type":"debugpy","request":"launch","program":"${workspaceFolder}/pyGCodeDecode/examples/benchy.py","console":"integratedTerminal"} + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 02940a7..d8fb24f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,8 @@ } ], "python.testing.pytestArgs": [ - "tests" + "tests", + "-s" ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, diff --git a/README.md b/README.md index 561de2a..47f7679 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ pygcd plot --gcode ## Creating a script using pyGCD -Example simulations are provided in [./examples/](https://github.com/FAST-LB/pyGCodeDecode/blob/main/examples/) and can be modified to suit your needs. If you want to start from scratch, the following instructions will help you setup and run a simulation. +Example simulations are provided in [./examples/](https://github.com/FAST-LB/pyGCodeDecode/blob/main/pyGCodeDecode/examples/) and can be modified to suit your needs. If you want to start from scratch, the following instructions will help you setup and run a simulation. ### Define your printer defaults in a `.yaml` file @@ -86,7 +86,7 @@ prusa_mini: vY: 180 vZ: 12 vE: 80 - firmware: marlin_jerk + firmware: prusa ``` The default settings usually are machine specific and often can be read from the printer using a serial connection by sending a GCode command. You can use `M503` for Marlin, Prusa and some other firmwares. @@ -104,13 +104,7 @@ from pyGCodeDecode import gcode_interpreter 1. Load your setup `.yaml` file through: ```python -setup = gcode_interpreter.setup(filename=r"e./pygcodedecode/data/default_printer_presets.yaml") -``` - -1. Select your printer from the setup by name: - -```python -setup.select_printer("prusa_mini") +setup = gcode_interpreter.setup(filename=r"./pyGCodeDecode/data/default_printer_presets.yaml", printer="prusa_mini") ``` 1. You can optionally set or modify custom properties after loading the setup: @@ -138,19 +132,22 @@ simulation.get_values(t=2.6) You can visualize the GCode by plotting it in 3D: ```python -simulation.plot_3d() +from pyGCodeDecode.plotter import plot_3d +plot_3d(simulation) ``` pyGCD can also be used to create files defining an event series for ABAQUS simulations. ```python +from pyGCodeDecode.abaqus_file_generator import generate_abaqus_event_series + generate_abaqus_event_series( simulation=simulation, - filpath="path/to/event_series.csv" + filepath="path/to/event_series.csv" ) ``` -For more in depth information have a look into the [documentation](https://github.com/FAST-LB/pyGCodeDecode/blob/main/doc.md). +For more in depth information have a look into the [documentation](https://fast-lb.github.io/pyGCodeDecode/). ## Supported GCode commands diff --git a/doc.md b/doc.md deleted file mode 100644 index 01f335f..0000000 --- a/doc.md +++ /dev/null @@ -1,1926 +0,0 @@ - - -# pyGCodeDecode.abaqus\_file\_generator - -Module for generating Abaqus .inp files for AMSIM. - - - -#### generate\_abaqus\_event\_series - -```python -def generate_abaqus_event_series( - simulation: gi.simulation, - filepath: str = "pyGcodeDecode_abaqus_events.inp", - tolerance: float = 1e-12, - output_unit_system: str = None) -> tuple -``` - -Generate abaqus event series. - -**Arguments**: - -- `simulation` _gi.simulation_ - simulation instance -- `filepath` _string, default = "pyGcodeDecode_abaqus_events.inp"_ - output file path -- `tolerance` _float, default = 1e-12_ - tolerance to determine whether extrusion is happening -- `output_unit_system` _str, optional_ - Unit system for the output. - The one from the simulation is used, in None is specified. - - -**Returns**: - -- `tuple` - the event series as a tuple for use in ABAQUS-Python - - - -# pyGCodeDecode.cli - -The CLI for the pyGCodeDecode package. - - - -# pyGCodeDecode.examples.benchy - -Simulating the G-code of a 3DBenchy from PrusaSlicer on a Prusa MINI. - - - -#### benchy\_example - -```python -def benchy_example() -``` - -Extensive example for the usage of pyGCodeDecode simulating G-code for the famous 3DBenchy. - - - -# pyGCodeDecode.examples.brace - -Minimal example simulating the G-code of a brace from Aura-slicer on an Anisoprint Composer A4. - - - -#### brace\_example - -```python -def brace_example() -``` - -Minimal example for the usage of pyGCodeDecode simulating the G-code of a brace. - - - -# pyGCodeDecode.gcode\_interpreter - -GCode Interpreter Module. - - - -#### update\_progress - -```python -def update_progress(progress: float, name: str = "Percent") -> None -``` - -Display or update a console progress bar. - -**Arguments**: - -- `progress` - float between 0 and 1 for percentage, < 0 represents a 'halt', > 1 represents 100% -- `name` - (string, default = "Percent") customizable name for progress bar - - - -#### generate\_planner\_blocks - -```python -def generate_planner_blocks(states: List[state], firmware=None) -``` - -Convert list of states to trajectory repr. by planner blocks. - -**Arguments**: - -- `states` - (list[state]) list of states -- `firmware` - (string, default = None) select firmware by name - - -**Returns**: - - block_list (list[planner_block]) list of all planner blocks to complete travel between all states - - - -#### find\_current\_segment - -```python -def find_current_segment(path: List[segment], - t: float, - last_index: int = None, - keep_position: bool = False) -``` - -Find the current segment. - -**Arguments**: - -- `path` - (list[segment]) all segments to be searched -- `t` - (float) time of search -- `last_index` - (int) last found index for optimizing search -- `keep_position` - (bool) keeps position of last segment, use this when working with gaps of no movement between segments - - -**Returns**: - -- `segment` - (segment) the segment which defines movement at that point in time -- `last_index` - (int) last index where something was found, search speed optimization possible - - - -#### unpack\_blocklist - -```python -def unpack_blocklist(blocklist: List[planner_block]) -> List[segment] -``` - -Return list of segments by unpacking list of planner blocks. - -**Arguments**: - -- `blocklist` - (list[planner_block]) list of planner blocks - - -**Returns**: - -- `path` - (list[segment]) list of all segments - - - -## simulation Objects - -```python -class simulation() -``` - -Simulation of .gcode with given machine parameters. - - - -#### \_\_init\_\_ - -```python -def __init__(gcode_path: pathlib.Path, - machine_name: str = None, - initial_machine_setup: "setup" = None, - output_unit_system: str = "SImm") -``` - -Initialize the Simulation of a given G-code with initial machine setup or default machine. - -- Generate all states from GCode. -- Connect states with planner blocks, consisting of segments -- Self correct inconsistencies. - -**Arguments**: - -- `gcode_path` - (Path) path to GCode - machine name: (string, default = None) name of the default machine to use -- `initial_machine_setup` - (setup, default = None) setup instance -- `output_unit_system` - (string, default = "SImm") available unit systems: SI, SImm & inch - - -**Example**: - -```python -gcode_interpreter.simulation(gcode_path=r"path/to/part.gcode", initial_machine_setup=printer_setup) -``` - - - -#### plot\_2d\_position - -```python -def plot_2d_position( - filepath: pathlib.Path = pathlib.Path("trajectory_2D.png"), - colvar="Velocity", - show_points=False, - colvar_spatial_resolution=1, - dpi=400, - scaled=True, - show=False) -``` - -Plot 2D position (XY plane) with matplotlib (unmaintained). - - - -#### plot\_3d - -```python -def plot_3d(extrusion_only: bool = True, - screenshot_path: pathlib.Path = None, - vtk_path: pathlib.Path = None, - mesh: pv.MultiBlock = None) -> pv.MultiBlock -``` - -3D Plot with PyVista. - -**Arguments**: - -- `extrusion_only` _bool, optional_ - Plot only parts where material is extruded. Defaults to True. -- `screenshot_path` _pathlib.Path, optional_ - Path to screenshot to be saved. Prevents interactive plot. Defaults to None. -- `vtk_path` _pathlib.Path, optional_ - Path to vtk to be saved. Prevents interactive plot. Defaults to None. -- `mesh` _pv.MultiBlock, optional_ - A pyvista mesh from a previous run to avoid running the mesh generation again. Defaults to None. - - -**Returns**: - -- `pv.MultiBlock` - The mesh used in the plot so it can be used (e.g. in subsequent plots). - - - -#### plot\_vel - -```python -def plot_vel(axis: Tuple[str] = ("x", "y", "z", "e"), - show: bool = True, - show_planner_blocks: bool = True, - show_segments: bool = False, - show_jv: bool = False, - time_steps: Union[int, str] = "constrained", - filepath: pathlib.Path = None, - dpi: int = 400) -> Figure -``` - -Plot axis velocity with matplotlib. - -**Arguments**: - -- `axis` - (tuple(string), default = ("x", "y", "z", "e")) select plot axis -- `show` - (bool, default = True) show plot and return plot figure -- `show_planner_blocks` - (bool, default = True) show planner_blocks as vertical lines -- `show_segments` - (bool, default = False) show segments as vertical lines -- `show_jv` - (bool, default = False) show junction velocity as x -- `time_steps` - (int or string, default = "constrained") number of time steps or constrain plot vertices to segment vertices -- `filepath` - (Path, default = None) save fig as image if filepath is provided -- `dpi` - (int, default = 400) select dpi - - -**Returns**: - - (optionally) -- `fig` - (figure) - - - -#### trajectory\_self\_correct - -```python -def trajectory_self_correct() -``` - -Self correct all blocks in the blocklist with self_correction() method. - - - -#### get\_values - -```python -def get_values(t: float, output_unit_system: str = None) -> Tuple[List[float]] -``` - -Return unit system scaled values for vel and pos. - -**Arguments**: - -- `t` - (float) time -- `output_unit_system` _str, optional_ - Unit system for the output. - The one from the simulation is used, in None is specified. - - -**Returns**: - -- `list` - [vel_x, vel_y, vel_z, vel_e] velocity -- `list` - [pos_x, pos_y, pos_z, pos_e] position - - - -#### check\_initial\_setup - -```python -def check_initial_setup(initial_machine_setup) -``` - -Check the printer Dict for typos or missing parameters and raise errors if invalid. - -**Arguments**: - -- `initial_machine_setup` - (dict) initial machine setup dictionary - - - -#### print\_summary - -```python -def print_summary(start_time: float) -``` - -Print simulation summary to console. - -**Arguments**: - -- `start_time` _float_ - time when the simulation run was started - - - -#### refresh - -```python -def refresh(new_state_list: List[state] = None) -``` - -Refresh simulation. Either through new state list or by rerunning the self.states as input. - -**Arguments**: - -- `new_state_list` - (list[state], default = None) new list of states, if None is provided, existing states get resimulated - - - -#### extrusion\_extent - -```python -def extrusion_extent(output_unit_system: str = None) -> np.ndarray -``` - -Return scaled xyz min & max while extruding. - -**Arguments**: - -- `output_unit_system` _str, optional_ - Unit system for the output. - The one from the simulation is used, in None is specified. - - -**Raises**: - -- `ValueError` - if nothing is extruded - - -**Returns**: - -- `np.ndarray` - extent of extruding positions - - - -#### extrusion\_max\_vel - -```python -def extrusion_max_vel(output_unit_system: str = None) -> np.float64 -``` - -Return scaled maximum velocity while extruding. - -**Arguments**: - -- `output_unit_system` _str, optional_ - Unit system for the output. - The one from the simulation is used, in None is specified. - - -**Returns**: - -- `max_vel` - (np.float64) maximum travel velocity while extruding - - - -#### save\_summary - -```python -def save_summary(filepath: Union[pathlib.Path, str]) -``` - -Save summary to .yaml file. - -**Arguments**: - -- `filepath` _pathlib.Path | str_ - path to summary file - - Saved data keys: - - filename (string, filename) - - t_end (float, end time) - - x/y/z _min/_max (float, extent where positive extrusion) - - max_extrusion_travel_velocity (float, maximum travel velocity where positive extrusion) - - - -#### get\_scaling\_factor - -```python -def get_scaling_factor(output_unit_system: str = None) -> float -``` - -Get a scaling factor to convert lengths from mm to another supported unit system. - -**Arguments**: - -- `output_unit_system` _str, optional_ - Wanted output unit system. - Uses the one specified for the simulation on None is specified. - - -**Returns**: - -- `float` - scaling factor - - - -## setup Objects - -```python -class setup() -``` - -Setup for printing simulation. - - - -#### \_\_init\_\_ - -```python -def __init__(presets_file: str, - printer: str = None, - layer_cue: str = None) -> None -``` - -Create simulation setup. - -**Arguments**: - -- `presets_file` - (string) choose setup yaml file with printer presets -- `printer` - (string) select printer from preset file -- `layer_cue` - (string) set slicer specific layer change cue from comment - - - -#### load\_setup - -```python -def load_setup(filepath) -``` - -Load setup from file. - -**Arguments**: - -- `filepath` - (string) specify path to setup file - - - -#### select\_printer - -```python -def select_printer(printer_name) -``` - -Select printer by name. - -**Arguments**: - -- `printer_name` - (string) select printer by name - - - -#### set\_initial\_position - -```python -def set_initial_position(initial_position: Union[tuple, dict], - input_unit_system: str = None) -``` - -Set initial Position. - -**Arguments**: - -- `initial_position` - (tuple or dict) set initial position as tuple of len(4) or dictionary with keys: {X, Y, Z, E}. -- `input_unit_system` _str, optional_ - Wanted input unit system. - Uses the one specified for the setup if None is specified. - - -**Example**: - -```python -setup.set_initial_position((1, 2, 3, 4)) -setup.set_initial_position({"X": 1, "Y": 2, "Z": 3, "E": 4}) -``` - - - -#### set\_property - -```python -def set_property(property_dict: dict) -``` - -Overwrite or add a property to the printer dictionary. Printer has to be selected through select_printer() beforehand. - -**Arguments**: - -- `property_dict` - (dict) set or add property to the setup - - -**Example**: - -```python -setup.set_property({"layer_cue": "LAYER_CHANGE"}) -``` - - - -#### get\_dict - -```python -def get_dict() -> dict -``` - -Return the setup for the selected printer. - -**Returns**: - -- `return_dict` - (dict) setup dictionary - - - -#### get\_scaling\_factor - -```python -def get_scaling_factor(input_unit_system: str = None) -> float -``` - -Get a scaling factor to convert lengths from mm to another supported unit system. - -**Arguments**: - -- `input_unit_system` _str, optional_ - Wanted input unit system. - Uses the one specified for the setup if None is specified. - - -**Returns**: - -- `float` - scaling factor - - - -# pyGCodeDecode.junction\_handling - -Junction handling module. - - - -## junction\_handling Objects - -```python -class junction_handling() -``` - -Junction handling super class. - - - -#### connect\_state - -```python -def connect_state(state_A: state, state_B: state) -``` - -Connect two states and generates the velocity for the move from state_A to state_B. - -**Arguments**: - -- `state_A` - (state) start state -- `state_B` - (state) end state - - -**Returns**: - -- `velocity` - (float) the target velocity for that travel move - - - -#### calc\_vel\_next - -```python -def calc_vel_next() -``` - -Return the target velocity for the following move. - - - -#### get\_target\_vel - -```python -def get_target_vel() -``` - -Return target velocity. - - - -#### \_\_init\_\_ - -```python -def __init__(state_A: state, state_B: state) -``` - -Initialize the junction handling. - -**Arguments**: - -- `state_A` - (state) start state -- `state_B` - (state) end state - - - -#### get\_junction\_vel - -```python -def get_junction_vel() -``` - -Return default junction velocity of zero. - -**Returns**: - -- `0` - zero for default full stop junction handling - - - -## junction\_handling\_marlin\_jd Objects - -```python -class junction_handling_marlin_jd(junction_handling) -``` - -Marlin specific junction handling with Junction Deviation. - - - -#### calc\_JD - -```python -def calc_JD(vel_0: velocity, vel_1: velocity, p_settings: state.p_settings) -``` - -Calculate junction deviation velocity from 2 velocitys. - -**Reference:** - -[https://onehossshay.wordpress.com/2011/09/24/improving_grbl_cornering_algorithm/](https://onehossshay.wordpress.com/2011/09/24/improving_grbl_cornering_algorithm/) -[http://blog.kyneticcnc.com/2018/10/computing-junction-deviation-for-marlin.html](http://blog.kyneticcnc.com/2018/10/computing-junction-deviation-for-marlin.html) - - -**Arguments**: - -- `vel_0` - (velocity) entry -- `vel_1` - (velocity) exit -- `p_settings` - (state.p_settings) print settings - - -**Returns**: - -- `velocity` - (float) velocity abs value - - - -#### \_\_init\_\_ - -```python -def __init__(state_A: state, state_B: state) -``` - -Marlin specific junction velocity calculation with Junction Deviation. - -**Arguments**: - -- `state_A` - (state) start state -- `state_B` - (state) end state - - - -#### get\_junction\_vel - -```python -def get_junction_vel() -``` - -Return junction velocity. - -**Returns**: - -- `junction_vel` - (float) junction velocity - - - -## junction\_handling\_marlin\_jerk Objects - -```python -class junction_handling_marlin_jerk(junction_handling) -``` - -Marlin classic jerk specific junction handling. - -**Reference** -[https://github.com/MarlinFirmware/Marlin/pull/8887](https://github.com/MarlinFirmware/Marlin/pull/8887) -[https://github.com/MarlinFirmware/Marlin/pull/8888](https://github.com/MarlinFirmware/Marlin/pull/8888) -[https://github.com/MarlinFirmware/Marlin/issues/367#issuecomment-12505768](https://github.com/MarlinFirmware/Marlin/issues/367#issuecomment-12505768) - - - -#### \_\_init\_\_ - -```python -def __init__(state_A: state, state_B: state) -``` - -Marlin classic jerk specific junction velocity calculation. - -**Arguments**: - -- `state_A` - (state) start state -- `state_B` - (state) end state - - - -#### calc\_j\_vel - -```python -def calc_j_vel() -``` - -Calculate the junction velocity. - - - -#### get\_junction\_vel - -```python -def get_junction_vel() -``` - -Return the calculated junction velocity. - -**Returns**: - -- `junction_vel` - (float) junction velocity - - - -## junction\_handling\_klipper Objects - -```python -class junction_handling_klipper(junction_handling) -``` - -Klipper specific junction handling. - -- similar junction deviation calc -- corner vel set by: square_corner_velocity - end_velocity^2 = start_velocity^2 + 2*accel*move_distance - for 90deg turn -- todo: smoothed look ahead - -**Reference:** -[https://www.klipper3d.org/Kinematics.html](https://www.klipper3d.org/Kinematics.html) -[https://github.com/Klipper3d/klipper/blob/ea2f6bc0f544132738c7f052ffcc586fa884a19a/klippy/toolhead.py](https://github.com/Klipper3d/klipper/blob/ea2f6bc0f544132738c7f052ffcc586fa884a19a/klippy/toolhead.py) - - - -#### \_\_init\_\_ - -```python -def __init__(state_A: state, state_B: state) -``` - -Klipper specific junction velocity calculation. - -**Arguments**: - -- `state_A` - (state) start state -- `state_B` - (state) end state - - - -#### calc\_j\_delta - -```python -def calc_j_delta() -``` - -Calculate the junction deviation with klipper specific values. - -The jerk value represents the square_corner_velocity! - - - -#### calc\_j\_vel - -```python -def calc_j_vel() -``` - -Calculate the junction velocity. - - - -#### get\_junction\_vel - -```python -def get_junction_vel() -``` - -Return the calculated junction velocity. - -**Returns**: - -- `junction_vel` - (float) junction velocity - - - -## junction\_handling\_MKA Objects - -```python -class junction_handling_MKA(junction_handling) -``` - -Anisoprint A4 like junction handling. - -**Reference:** -[https://github.com/anisoprint/MKA-firmware/blob/6e02973b1b8f325040cc3dbf66ac545ffc5c06b3/src/core/planner/planner.cpp#L1830](https://github.com/anisoprint/MKA-firmware/blob/6e02973b1b8f325040cc3dbf66ac545ffc5c06b3/src/core/planner/planner.cpp#L1830) - - - -#### \_\_init\_\_ - -```python -def __init__(state_A: state, state_B: state) -``` - -Marlin classic jerk specific junction velocity calculation. - -**Arguments**: - -- `state_A` - (state) start state -- `state_B` - (state) end state - - - -#### calc\_j\_vel - -```python -def calc_j_vel() -``` - -Calculate the junction velocity. - - - -#### get\_junction\_vel - -```python -def get_junction_vel() -``` - -Return the calculated junction velocity. - -**Returns**: - -- `junction_vel` - (float) junction velocity - - - -# pyGCodeDecode.planner\_block - -Planner block Module. - - - -## planner\_block Objects - -```python -class planner_block() -``` - -Planner Block Class. - - - -#### move\_maker2 - -```python -def move_maker2(v_end) -``` - -Calculate the correct move type (trapezoidal,triangular or singular) and generate the corresponding segments. - -**Arguments**: - -- `vel_end` - (velocity) target velocity for end of move - - - -#### self\_correction - -```python -def self_correction(tolerance=float("1e-12")) -``` - -Check for interfacing vel and self correct. - - - -#### timeshift - -```python -def timeshift(delta_t: float) -``` - -Shift planner block in time. - -**Arguments**: - -- `delta_t` - (float) time to be shifted - - - -#### extrusion\_block\_max\_vel - -```python -def extrusion_block_max_vel() -> Union[np.ndarray, None] -``` - -Return max vel from planner block while extruding. - -**Returns**: - -- `block_max_vel` - (np.ndarray 1x4) maximum axis velocity while extruding in block or None - if no extrusion is happening - - - -#### \_\_init\_\_ - -```python -def __init__(state: state, prev_block: "planner_block", firmware=None) -``` - -Calculate and store planner block consisting of one or multiple segments. - -**Arguments**: - -- `state` - (state) the current state -- `prev_block` - (planner_block) previous planner block -- `firmware` - (string, default = None) firmware selection for junction - - - -#### prev\_block - -```python -@property -def prev_block() -``` - -Define prev_block as property. - - - -#### next\_block - -```python -@property -def next_block() -``` - -Define next_block as property. - - - -#### \_\_str\_\_ - -```python -def __str__() -> str -``` - -Create string from planner block. - - - -#### \_\_repr\_\_ - -```python -def __repr__() -> str -``` - -Represent planner block. - - - -#### get\_segments - -```python -def get_segments() -``` - -Return segments, contained by the planner block. - - - -#### get\_block\_travel - -```python -def get_block_travel() -``` - -Return the travel length of the planner block. - - - -# pyGCodeDecode.state - -State module with state. - - - -## state Objects - -```python -class state() -``` - -State contains a Position and Printing Settings (p_settings) to apply for the corresponding move to this State. - - - -## p\_settings Objects - -```python -class p_settings() -``` - -Store Printing Settings. - - - -#### \_\_init\_\_ - -```python -def __init__(p_acc, jerk, vX, vY, vZ, vE, speed, units="SImm") -``` - -Initialize printing settings. - -**Arguments**: - -- `p_acc` - (float) printing acceleration -- `jerk` - (float) jerk or similar -- `vX` - (float) max x velocity -- `vY` - (float) max y velocity -- `vZ` - (float) max z velocity -- `vE` - (float) max e velocity -- `speed` - (float) default target velocity -- `units` - (string, default = "SImm") unit settings - - - -#### \_\_str\_\_ - -```python -def __str__() -> str -``` - -Create summary string for p_settings. - - - -#### \_\_repr\_\_ - -```python -def __repr__() -> str -``` - -Define representation. - - - -#### \_\_init\_\_ - -```python -def __init__(state_position: position = None, - state_p_settings: p_settings = None) -``` - -Initialize a state. - -**Arguments**: - -- `state_position` - (position) state position -- `state_p_settings` - (p_settings) state printing settings - - - -#### state\_position - -```python -@property -def state_position() -``` - -Define property state_position. - - - -#### state\_p\_settings - -```python -@property -def state_p_settings() -``` - -Define property state_p_settings. - - - -#### line\_number - -```python -@property -def line_number() -``` - -Define property line_nmbr. - - - -#### line\_number - -```python -@line_number.setter -def line_number(nmbr) -``` - -Set line number. - -**Arguments**: - -- `nmbr` - (int) line number - - - -#### next\_state - -```python -@property -def next_state() -``` - -Define property next_state. - - - -#### next\_state - -```python -@next_state.setter -def next_state(state: "state") -``` - -Set next state. - -**Arguments**: - -- `state` - (state) next state - - - -#### prev\_state - -```python -@property -def prev_state() -``` - -Define property prev_state. - - - -#### prev\_state - -```python -@prev_state.setter -def prev_state(state: "state") -``` - -Set previous state. - -**Arguments**: - -- `state` - (state) previous state - - - -#### \_\_str\_\_ - -```python -def __str__() -> str -``` - -Generate string for representation. - - - -#### \_\_repr\_\_ - -```python -def __repr__() -> str -``` - -Call __str__() for representation. - - - -# pyGCodeDecode.state\_generator - -State generator module. - - - -#### arg\_extract - -```python -def arg_extract(string: str, key_dict: dict) -> dict -``` - -Extract arguments from known command dictionaries. - -**Arguments**: - -- `string` - (str) string of Commands -- `key_dict` - (dict) dictionary with known commands and subcommands - - -**Returns**: - -- `dict` - (dict) dictionary with all found keys and their arguments - - - -#### read\_gcode\_to\_dict\_list - -```python -def read_gcode_to_dict_list(filepath: pathlib.Path) -> List[dict] -``` - -Read gcode from .gcode file. - -**Arguments**: - -- `filename` - (Path) filepath of the .gcode file - - -**Returns**: - -- `dict_list` - (list[dict]) list with every line as dict - - - -#### dict\_list\_traveler - -```python -def dict_list_traveler(line_dict_list: List[dict], - initial_machine_setup: dict) -> List[state] -``` - -Convert the line dictionary to a state. - -**Arguments**: - -- `line_dict_list` - (dict) dict list with commands -- `initial_machine_setup` - (dict) dict with initial machine setup [absolute_position, absolute_extrusion, units, initial_position...] - - -**Returns**: - -- `state_list` - (list[state]) all states in a list - - - -#### check\_for\_unsupported\_commands - -```python -def check_for_unsupported_commands(line_dict_list: dict) -> dict -``` - -Search for unsupported commands used in the G-code, warn the user and return the occurrences. - -**Arguments**: - -- `line_dict_list` _dict_ - list of dicts containing all commands appearing - - -**Returns**: - -- `dict` - a dict containing the appearing unsupported commands and how often they appear. - - - -#### generate\_states - -```python -def generate_states(filepath: pathlib.Path, - initial_machine_setup: dict) -> List[state] -``` - -Generate state list from GCode file. - -**Arguments**: - -- `filepath` - (Path) filepath to GCode -- `initial_machine_setup` - (dict) dictionary with machine setup - - -**Returns**: - -- `states` - (list[states]) all states in a list - - - -# pyGCodeDecode.tools - -Tools for pyGCD. - - - -#### save\_layer\_metrics - -```python -def save_layer_metrics(simulation: simulation, - filepath: Union[pathlib.Path, - str] = "./layer_metrics.csv", - locale: str = None, - delimiter: str = ";") -``` - -Print out print times, distance traveled and the average travel speed to a csv-file. - -**Arguments**: - -- `simulation` - (simulation) simulation instance -- `filepath` - (Path | string, default = "./layer_metrics.csv") file name -- `locale` - (string, default = None) select locale settings, e.g. "en_US.utf8" "de_DE.utf8", None = use system locale -- `delimiter` - (string, default = ";") select delimiter - - Layers are detected using the given layer cue. - - - -#### write\_submodel\_times - -```python -def write_submodel_times(simulation: simulation, - sub_orig: list, - sub_side_x_len: float, - sub_side_y_len: float, - sub_side_z_len: float, - filename="submodel_times.yaml", - **kwargs) -``` - -Write the submodel entry and exit times to a yaml file. - -**Arguments**: - -- `simulation` - (simulation) the simulation instance to analyze -- `sub_orig` - (list with [xcoord, ycoord, zcoord]) the origin of the submodel control volume -- `sub_side_len` - (float) the side length of the submodel control volume -- `filename` - (string) yaml filename -- `**kwargs` - (any) provide additional info to write into the yaml file - - - -# pyGCodeDecode.utils - -Utilitys. - -Utils for the GCode Reader contains: -- vector 4D - - velocity - - position - - - -## vector\_4D Objects - -```python -class vector_4D() -``` - -The vector_4D class stores 4D vector in x,y,z,e. - -**Supports:** -- str -- add -- sub -- mul (scalar) -- truediv (scalar) -- eq - - - -#### \_\_init\_\_ - -```python -def __init__(*args) -``` - -Store 3D position + extrusion axis. - -**Arguments**: - -- `args` - coordinates as arguments x,y,z,e or (tuple or list) [x,y,z,e] - - - -#### \_\_str\_\_ - -```python -def __str__() -> str -``` - -Return string representation. - - - -#### \_\_add\_\_ - -```python -def __add__(other) -``` - -Add functionality for 4D vectors. - -**Arguments**: - -- `other` - (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray') - - -**Returns**: - -- `add` - (self) component wise addition - - - -#### \_\_sub\_\_ - -```python -def __sub__(other) -``` - -Sub functionality for 4D vectors. - -**Arguments**: - -- `other` - (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray') - - -**Returns**: - -- `sub` - (self) component wise subtraction - - - -#### \_\_mul\_\_ - -```python -def __mul__(other) -``` - -Scalar multiplication functionality for 4D vectors. - -**Arguments**: - -- `other` - (float or int) - - -**Returns**: - -- `mul` - (self) scalar multiplication, scaling - - - -#### \_\_truediv\_\_ - -```python -def __truediv__(other) -``` - -Scalar division functionality for 4D Vectors. - -**Arguments**: - -- `other` - (float or int) - - -**Returns**: - -- `div` - (self) scalar division, scaling - - - -#### \_\_eq\_\_ - -```python -def __eq__(other) -``` - -Check for equality and return True if equal. - -**Arguments**: - -- `other` - (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray') - - -**Returns**: - -- `eq` - (bool) true if equal (with tolerance) - - - -#### get\_vec - -```python -def get_vec(withExtrusion=False) -> List[float] -``` - -Return the 4D vector, optionally with extrusion. - -**Arguments**: - -- `withExtrusion` - (bool, default = False) choose if vec repr contains extrusion - - -**Returns**: - -- `vec` - (list[3 or 4]) with (x,y,z,(optionally e)) - - - -#### get\_norm - -```python -def get_norm(withExtrusion=False) -> float -``` - -Return the 4D vector norm. Optional with extrusion. - -**Arguments**: - -- `withExtrusion` - (bool, default = False) choose if norm contains extrusion - - -**Returns**: - -- `norm` - (float) length/norm of 3D or 4D vector - - - -## velocity Objects - -```python -class velocity(vector_4D) -``` - -4D - Velocity object for (Cartesian) 3D printer. - - - -#### \_\_str\_\_ - -```python -def __str__() -> str -``` - -Print out velocity. - - - -#### get\_norm\_dir - -```python -def get_norm_dir(withExtrusion=False) -``` - -Get normalized vector (regarding travel distance), if only extrusion occurs, normalize to extrusion length. - -**Arguments**: - -- `withExtrusion` - (bool, default = False) choose if norm dir contains extrusion - - -**Returns**: - -- `dir` - (list[3 or 4]) normalized direction vector as list - - - -#### avoid\_overspeed - -```python -def avoid_overspeed(p_settings) -``` - -Return velocity without any axis overspeed. - -**Arguments**: - -- `p_settings` - (p_settings) printing settings - - -**Returns**: - -- `vel` - (velocity) constrained by max velocity - - - -#### not\_zero - -```python -def not_zero() -``` - -Return True if velocity is not zero. - -**Returns**: - -- `not_zero` - (bool) true if velocity is not zero - - - -#### is\_extruding - -```python -def is_extruding() -``` - -Return True if extrusion velocity is not zero. - -**Returns**: - -- `is_extruding` - (bool) true if positive extrusion velocity - - - -## position Objects - -```python -class position(vector_4D) -``` - -4D - Position object for (Cartesian) 3D printer. - - - -#### \_\_str\_\_ - -```python -def __str__() -> str -``` - -Print out position. - - - -#### is\_travel - -```python -def is_travel(other) -> bool -``` - -Return True if there is travel between self and other position. - -**Arguments**: - -- `other` - (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray') - - -**Returns**: - -- `is_travel` - (bool) true if between self and other is distance - - - -#### is\_extruding - -```python -def is_extruding(other: "position", ignore_retract: bool = True) -> bool -``` - -Return True if there is extrusion between self and other position. - -**Arguments**: - -- `other` - (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray') -- `ignore_retract` - (bool, default = True) if true ignore retract movements else retract is also extrusion - - -**Returns**: - -- `is_extruding` - (bool) true if between self and other is extrusion - - - -#### get\_t\_distance - -```python -def get_t_distance(other=None, withExtrusion=False) -> float -``` - -Calculate the travel distance between self and other position. If none is provided, zero will be used. - -**Arguments**: - -- `other` - (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray', default = None) -- `withExtrusion` - (bool, default = False) use or ignore extrusion - - -**Returns**: - -- `travel` - (float) travel or extrusion and travel distance - - - -## segment Objects - -```python -class segment() -``` - -Store Segment data for linear 4D Velocity function segment. - -contains: time, position, velocity -**Supports** -- str - -**Additional methods** -- move_segment_time: moves Segment in time by a specified interval -- get_velocity: returns the calculated Velocity for all axis at a given point in time -- get_position: returns the calculated Position for all axis at a given point in time - -**Class method** -- create_initial: returns the artificial initial segment where everything is at standstill, intervall length = 0 -- self_check: returns True if all self checks have been successfull - - - -#### \_\_init\_\_ - -```python -def __init__(t_begin: float, - t_end: float, - pos_begin: position, - vel_begin: velocity, - pos_end: position = None, - vel_end: velocity = None) -``` - -Initialize a segment. - -**Arguments**: - -- `t_begin` - (float) begin of segment -- `t_end` - (float) end of segment -- `pos_begin` - (position) beginning position of segment -- `vel_begin` - (velocity) beginning velocity of segment -- `pos_end` - (position, default = None) ending position of segment -- `vel_end` - (velocity, default = None) ending velocity of segment - - - -#### \_\_str\_\_ - -```python -def __str__() -> str -``` - -Create string from segment. - - - -#### \_\_repr\_\_ - -```python -def __repr__() -``` - -Segment representation. - - - -#### move\_segment\_time - -```python -def move_segment_time(delta_t: float) -``` - -Move segment in time. - -**Arguments**: - -- `delta_t` - (float) time to be shifted - - - -#### get\_velocity - -```python -def get_velocity(t: float) -> velocity -``` - -Get current velocity of segment at a certain time. - -**Arguments**: - -- `t` - (float) time - - -**Returns**: - -- `current_vel` - (velocity) velocity at time t - - - -#### get\_position - -```python -def get_position(t: float) -> position -``` - -Get current position of segment at a certain time. - -**Arguments**: - -- `t` - (float) time - - -**Returns**: - -- `pos` - (position) position at time t - - - -#### self\_check - -```python -def self_check(p_settings=None) -``` - -Check the segment for self consistency. - -todo: -- max acceleration - -**Arguments**: - -- `p_settings` - (p_setting, default = None) printing settings to verify - - - -#### is\_extruding - -```python -def is_extruding() -> bool -``` - -Return true if the segment is pos. extruding. - -**Returns**: - -- `is_extruding` - (bool) true if positive extrusion - - - -#### create\_initial - -```python -@classmethod -def create_initial(cls, initial_position: position = None) -``` - -Create initial static segment with (optionally) initial position else start from Zero. - -**Arguments**: - -- `initial_position` - (postion, default = None) position to begin segment series - - -**Returns**: - -- `segment` - (segment) initial beginning segment diff --git a/docs_manager.py b/docs_manager.py new file mode 100644 index 0000000..709b8b3 --- /dev/null +++ b/docs_manager.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Documentation generation script for pyGCodeDecode. + +This script automates the process of generating documentation using +pydoc-markdown and serving it with mkdocs. +""" + +import os +import subprocess +import sys +from pathlib import Path + + +def run_command(cmd, description): + """Run a shell command and handle errors.""" + print(f"πŸ”„ {description}...") + try: + result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True) + print(f"βœ… {description} completed successfully") + if result.stdout.strip(): + print(f" Output: {result.stdout.strip()}") + return True + except subprocess.CalledProcessError as e: + print(f"❌ {description} failed:") + print(f" Error: {e.stderr.strip()}") + return False + + +def generate_docs(): + """Generate API documentation using pydoc-markdown.""" + print("πŸ“š Generating API documentation...") + + # Change to project root directory + project_root = Path(__file__).parent + os.chdir(project_root) + + # Generate documentation + if not run_command("pydoc-markdown", "Generating API documentation with pydoc-markdown"): + return False + + print("βœ… Documentation generation completed!") + return True + + +def serve_docs(): + """Serve documentation using mkdocs.""" + print("🌐 Starting documentation server...") + + docs_dir = Path(__file__).parent / "docs" + os.chdir(docs_dir) + + print("Starting mkdocs server at http://127.0.0.1:8000") + print("Press Ctrl+C to stop the server") + + try: + subprocess.run("mkdocs serve", shell=True, check=True) + except KeyboardInterrupt: + print("\nπŸ‘‹ Documentation server stopped") + except subprocess.CalledProcessError as e: + print(f"❌ Failed to start mkdocs server: {e}") + return False + + return True + + +def build_docs(): + """Build static documentation files.""" + print("πŸ—οΈ Building static documentation...") + + docs_dir = Path(__file__).parent / "docs" + os.chdir(docs_dir) + + return run_command("mkdocs build", "Building static documentation") + + +def main(): + """Handle command line arguments and execute requested actions.""" + if len(sys.argv) < 2: + print("πŸ“– pyGCodeDecode Documentation Manager") + print("\nUsage:") + print(" python docs_manager.py generate - Generate API documentation") + print(" python docs_manager.py serve - Serve documentation locally") + print(" python docs_manager.py build - Build static documentation") + print(" python docs_manager.py all - Generate and serve documentation") + return + + command = sys.argv[1].lower() + + if command == "generate": + generate_docs() + elif command == "serve": + serve_docs() + elif command == "build": + if generate_docs(): + build_docs() + elif command == "all": + if generate_docs(): + serve_docs() + else: + print(f"❌ Unknown command: {command}") + print("Available commands: generate, serve, build, all") + + +if __name__ == "__main__": + main() diff --git a/pyGCodeDecode/abaqus_file_generator.py b/pyGCodeDecode/abaqus_file_generator.py index 253b41a..c390569 100644 --- a/pyGCodeDecode/abaqus_file_generator.py +++ b/pyGCodeDecode/abaqus_file_generator.py @@ -4,7 +4,7 @@ from pyGCodeDecode.helpers import custom_print -from . import gcode_interpreter as gi +from . import gcode_interpreter """ This script is to convert gcode into an event series as abaqus input @@ -21,24 +21,26 @@ def generate_abaqus_event_series( - simulation: gi.simulation, + simulation: gcode_interpreter.simulation, filepath: str = "pyGcodeDecode_abaqus_events.inp", tolerance: float = 1e-12, output_unit_system: str = None, + return_tuple: bool = False, ) -> tuple: """Generate abaqus event series. Args: - simulation (gi.simulation): simulation instance + simulation (gcode_interpreter.simulation): simulation instance filepath (string, default = "pyGcodeDecode_abaqus_events.inp"): output file path tolerance (float, default = 1e-12): tolerance to determine whether extrusion is happening output_unit_system (str, optional): Unit system for the output. The one from the simulation is used, in None is specified. + return_tuple (bool, default = False): return the event series as tuple. Returns: - tuple: the event series as a tuple for use in ABAQUS-Python + (optional) tuple: the event series as a tuple for use in ABAQUS-Python """ - unpacked = gi.unpack_blocklist(simulation.blocklist) + unpacked = gcode_interpreter.unpack_blocklist(simulation.blocklist) pos = [unpacked[0].pos_begin.get_vec(withExtrusion=True)] time = [0] for segment in unpacked: @@ -65,10 +67,11 @@ def generate_abaqus_event_series( with open(filepath, "w") as outfile: for time, pos in zip(time, pos): outfile.write( - f"{time},{round(scaling*pos[0], round_to)},{round(scaling*pos[1], round_to)},{round(scaling*pos[2], round_to)},{pos[3]}\n" + f"{float(time)},{round(scaling*pos[0], round_to)},{round(scaling*pos[1], round_to)},{round(scaling*pos[2], round_to)},{pos[3]}\n" ) - event_series_list.append((time, scaling * pos[0], scaling * pos[1], scaling * pos[2], pos[3])) + event_series_list.append((float(time), scaling * pos[0], scaling * pos[1], scaling * pos[2], pos[3])) - custom_print(f"ABAQUS event series written to: \n{outfile.name}") + custom_print(f"πŸ’Ύ ABAQUS event series written to πŸ‘‰ {outfile.name}") - return tuple(event_series_list) + if return_tuple: + return tuple(event_series_list) diff --git a/pyGCodeDecode/cli.py b/pyGCodeDecode/cli.py index 7b4e484..8da0b86 100644 --- a/pyGCodeDecode/cli.py +++ b/pyGCodeDecode/cli.py @@ -1,4 +1,21 @@ -"""The CLI for the pyGCodeDecode package.""" +"""The pyGCodeDecode CLI Module. + +Interact with pyGCodeDecode via the command line to run examples and plot GCode files. + +Features: + +- Run built-in examples: `brace`, `benchy` +- Plot GCode files with printer presets and output options +- Save simulation summaries, metrics, screenshots, and VTK files + +Usage Examples: + +- `pygcd --help` +- `pygcd run_example brace` +- `pygcd plot -g myfile.gcode` +- `pygcd plot -g myfile.gcode -p presets.yaml -pn my_printer` +- `pygcd plot -g myfile.gcode -o ./outputs -lc ";LAYER"` +""" import argparse import importlib.resources @@ -9,6 +26,7 @@ from pyGCodeDecode.examples.brace import brace_example from pyGCodeDecode.gcode_interpreter import setup, simulation from pyGCodeDecode.helpers import custom_print +from pyGCodeDecode.plotter import plot_3d from pyGCodeDecode.tools import save_layer_metrics @@ -29,22 +47,23 @@ def _find_gcode_file(specified_path: pathlib.Path | None) -> pathlib.Path: g_code_file = specified_path elif specified_path is not None: custom_print( - f"❌ The specified G-code:\n{specified_path.resolve()}\nis not valid.\n" "πŸ›‘ Exiting the program." + f"❌ The specified G-code:\n{specified_path.resolve()}\nis not valid.\n" "πŸ›‘ Exiting the program.", + lvl=1, ) exit() else: - custom_print("⚠️ No G-code file specified. Looking for a G-code file in the current directory... πŸ‘€") + custom_print("⚠️ No G-code file specified. Looking for a G-code file in the current directory... πŸ‘€", lvl=1) files_list = list(pathlib.Path.cwd().glob("*.gcode")) if files_list.__len__() == 0: - custom_print("❌ No G-code file found in the current directory.\n" "πŸ›‘ Exiting the program.") + custom_print("❌ No G-code file found in the current directory.\n" "πŸ›‘ Exiting the program.", lvl=1) exit() elif files_list.__len__() == 1: g_code_file = files_list[0] else: - custom_print("❌ Multiple G-code files found in the current directory:") + custom_print("❌ Multiple G-code files found in the current directory:", lvl=1) for file in files_list: - custom_print(f" - {file.resolve()}") - custom_print("πŸ›‘ Exiting the program.") + custom_print(f" - {file.resolve()}", lvl=1) + custom_print("πŸ›‘ Exiting the program.", lvl=1) exit() custom_print(f"βœ… Using the G-code file:\n{g_code_file.resolve()}") @@ -53,11 +72,12 @@ def _find_gcode_file(specified_path: pathlib.Path | None) -> pathlib.Path: def _get_presets_file(presets_file: pathlib.Path | None) -> pathlib.Path: """Get the machine setup from the presets file.""" if presets_file is None: - custom_print("⚠️ No presets file specified. Using the default presets shipped with pyGCD.") + custom_print("⚠️ No presets file specified. Using the default presets shipped with pyGCD.") presets_file = importlib.resources.files("pyGCodeDecode").joinpath("data/default_printer_presets.yaml") elif not presets_file.is_file(): custom_print( - f"❌ The specified presets file:\n{presets_file.resolve()}\nis not valid.\n" "πŸ›‘ Exiting the program." + f"❌ The specified presets file:\n{presets_file.resolve()}\nis not valid.\n" "πŸ›‘ Exiting the program.", + lvl=1, ) exit() else: @@ -71,18 +91,18 @@ def _get_out_dir(out_dir: pathlib.Path | None, g_code_file: pathlib.Path) -> pat answer = "" while answer.lower() not in ("y", "yes", "n", "no"): answer = input( - "⚠️ No output directory specified! Do you want to create one in the current working directory?" + "⚠️ No output directory specified! Do you want to create one in the current working directory?" "\nOtherwise no outputs will be saved!" "\nYou must answer with yes (y) or no (n)!\n" ) if answer.lower() in ["n", "no"]: - custom_print("⚠️ Not creating any output files.") + custom_print("⚠️ Not creating any output files.") return None elif answer.lower() in ["y", "yes"]: out_dir = pathlib.Path.cwd() / f"output_{g_code_file.stem}" - custom_print(f"βœ… Using the output directory:\n{out_dir.resolve()}") + custom_print(f"βœ… Using the output directory: {out_dir.resolve()}") return out_dir g_code_file = _find_gcode_file(args.gcode) @@ -93,7 +113,7 @@ def _get_out_dir(out_dir: pathlib.Path | None, g_code_file: pathlib.Path) -> pat printer_name = args.printer_name custom_print(f"βœ… Using the printer: {printer_name}") else: - custom_print("⚠️ No printer specified. Using the default printer Anisoprint A4.") + custom_print("⚠️ No printer specified. Using the default printer Anisoprint A4.") printer_name = "anisoprint_a4" # setting up the printer @@ -121,7 +141,8 @@ def _get_out_dir(out_dir: pathlib.Path | None, g_code_file: pathlib.Path) -> pat delimiter=",", ) # create a 3D-plot and save a VTK as well as a screenshot - mesh = sim.plot_3d( + mesh = plot_3d( + sim, extrusion_only=True, screenshot_path=out_dir / f"{g_code_file.stem}.png", vtk_path=out_dir / f"{g_code_file.stem}.vtk", @@ -130,10 +151,10 @@ def _get_out_dir(out_dir: pathlib.Path | None, g_code_file: pathlib.Path) -> pat mesh = None # create an interactive 3D-plot - sim.plot_3d(mesh=mesh) + plot_3d(sim, mesh=mesh) -def _main(*args): +def _main(args=None): """Entry point function for the command-line interface (CLI).""" global_parser = argparse.ArgumentParser( prog="pygcd", @@ -146,7 +167,7 @@ def _main(*args): version=__version__, ) - # subparsers vor various functions + # subparsers for various functions subparsers = global_parser.add_subparsers( title="subcommands", description="Functions accessible via this CLI.", @@ -213,10 +234,10 @@ def _main(*args): ) # parse the arguments - args = global_parser.parse_args() + parsed_args = global_parser.parse_args(args) # call the respective function specified by the subparser - if hasattr(args, "func"): - args.func(args) + if hasattr(parsed_args, "func"): + parsed_args.func(parsed_args) else: global_parser.print_help() diff --git a/pyGCodeDecode/data/default_printer_presets.yaml b/pyGCodeDecode/data/default_printer_presets.yaml index f2e4e68..8b9933b 100644 --- a/pyGCodeDecode/data/default_printer_presets.yaml +++ b/pyGCodeDecode/data/default_printer_presets.yaml @@ -1,54 +1,55 @@ # -*- coding: utf-8 -*- - -anisoprint_a4: +prusa_mini: # general properties nozzle_diam: 0.4 filament_diam: 1.75 # default settings p_vel: 35 - p_acc: 1000 - jerk: 10 + p_acc: 1250 + jerk: 8 # axis max speeds vX: 180 vY: 180 - vZ: 30 - vE: 33 - firmware: MKA + vZ: 12 + vE: 80 + firmware: prusa -prusa_mini: +anisoprint_a4: # general properties nozzle_diam: 0.4 filament_diam: 1.75 # default settings p_vel: 35 - p_acc: 1250 - jerk: 8 + p_acc: 1000 + jerk: 10 # axis max speeds vX: 180 vY: 180 - vZ: 12 - vE: 80 - firmware: marlin_jerk + vZ: 30 + vE: 33 + firmware: mka -prusa_mini_klipper: +ultimaker_2plus: # general properties nozzle_diam: 0.4 - filament_diam: 1.75 + filament_diam: 2.85 + volumetric_extrusion: True # true: mm3 with UltiGCode, else mm length # default settings - p_vel: 35 - p_acc: 2500 + p_vel: 85 + p_acc: 100 jerk: 8 # axis max speeds vX: 180 vY: 180 vZ: 12 vE: 80 - firmware: klipper + firmware: ultimaker debugging: # general properties nozzle_diam: 0.4 - filament_diam: 1.75 + filament_diam: 2.85 + volumetric_extrusion: True # true: mm3 with UltiGCode, else mm length # default settings p_vel: 85 p_acc: 100 @@ -58,4 +59,4 @@ debugging: vY: 180 vZ: 12 vE: 80 - firmware: + firmware: marlin # junction_deviation diff --git a/pyGCodeDecode/examples/benchy.py b/pyGCodeDecode/examples/benchy.py index 1d5310d..6d0f59e 100644 --- a/pyGCodeDecode/examples/benchy.py +++ b/pyGCodeDecode/examples/benchy.py @@ -5,6 +5,8 @@ from pyGCodeDecode.abaqus_file_generator import generate_abaqus_event_series from pyGCodeDecode.gcode_interpreter import setup, simulation +from pyGCodeDecode.helpers import custom_print +from pyGCodeDecode.plotter import plot_3d from pyGCodeDecode.tools import save_layer_metrics @@ -14,12 +16,12 @@ def benchy_example(): data_dir = importlib.resources.files("pyGCodeDecode").joinpath("examples/data/") output_dir = pathlib.Path.cwd() / "output_benchy_example" - print( + custom_print( "Running pyGCD's benchy example! πŸ›₯️" - "\nThis example illustrates an extensive use of the package: A gcode is simulated with default presets from a " + "\nThis example illustrates an extensive use of the package: \nA gcode is simulated with default presets from a " "file provided alongside this example. After the simulation, an interactive 3D-plot is shown." - "\nThe following files are saved to a new folder in your current directory: πŸ’Ύ\n", - output_dir.__str__(), + "\nThe following files are saved to a new folder in your current directory: ", + output_dir.__str__() + " πŸ’Ύ", "\n - a screenshot of the 3D-plot πŸ“Έ" "\n - a .vtk of the mesh πŸ•ΈοΈ" "\n - a summary of the simulation πŸ“" @@ -67,7 +69,8 @@ def benchy_example(): ) # create a 3D-plot and save a VTK as well as a screenshot - benchy_mesh = benchy_simulation.plot_3d( + benchy_mesh = plot_3d( + benchy_simulation, extrusion_only=True, screenshot_path=output_dir / "benchy.png", vtk_path=output_dir / "benchy.vtk", @@ -75,7 +78,7 @@ def benchy_example(): # create an interactive 3D-plot # the mesh from the previous run can be used to avoid generating a mesh again - benchy_simulation.plot_3d(mesh=benchy_mesh) + plot_3d(benchy_simulation, mesh=benchy_mesh) if __name__ == "__main__": diff --git a/pyGCodeDecode/examples/brace.py b/pyGCodeDecode/examples/brace.py index 989a7b8..3da5cb3 100644 --- a/pyGCodeDecode/examples/brace.py +++ b/pyGCodeDecode/examples/brace.py @@ -3,14 +3,16 @@ import importlib.resources from pyGCodeDecode.gcode_interpreter import simulation +from pyGCodeDecode.helpers import custom_print +from pyGCodeDecode.plotter import plot_3d def brace_example(): """Minimal example for the usage of pyGCodeDecode simulating the G-code of a brace.""" - print( + custom_print( "Running pyGCD's brace example! πŸ“Ž" "\nThis example illustrates the simplest use of the package: A gcode is simulated with default presets " - "provided by the package. After the simulation, an interactive 3D-plot is shown. No output is saved." + "\nprovided by the package. After the simulation, an interactive 3D-plot is shown. No output is saved." ) gcode_path = importlib.resources.files("pyGCodeDecode").joinpath("examples/data/brace.gcode") @@ -19,7 +21,7 @@ def brace_example(): brace_simulation = simulation(gcode_path=gcode_path, machine_name="anisoprint_a4") # create a 3D-plot - brace_simulation.plot_3d(extrusion_only=True) + plot_3d(brace_simulation, extrusion_only=True) if __name__ == "__main__": diff --git a/pyGCodeDecode/examples/data/bee.gcode b/pyGCodeDecode/examples/data/bee.gcode new file mode 100644 index 0000000..03b9d77 --- /dev/null +++ b/pyGCodeDecode/examples/data/bee.gcode @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd71d107cc7d4a12902437ca79a956c1bbce86f3e23fda4ae8916ee63e3f7afb +size 10187 diff --git a/pyGCodeDecode/examples/data/benchy.gcode b/pyGCodeDecode/examples/data/benchy.gcode index d9999da..a214d87 100644 --- a/pyGCodeDecode/examples/data/benchy.gcode +++ b/pyGCodeDecode/examples/data/benchy.gcode @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5f7799ab4b39630aa1b5c869672399c7cb5c5b2a158cd4a9204334495b64514d -size 5175490 +oid sha256:eb3e198460566f0317eaf032a5394100b8fe465bb85b9f836581c3ce11f01d2a +size 5175410 diff --git a/pyGCodeDecode/examples/data/cubhelix.gcode b/pyGCodeDecode/examples/data/cubhelix.gcode new file mode 100644 index 0000000..f451c07 --- /dev/null +++ b/pyGCodeDecode/examples/data/cubhelix.gcode @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64057d07f29f1f28f23a995e29a3c9428586c81752bc7646eccb5c770077d1bc +size 140525 diff --git a/pyGCodeDecode/examples/data/printer_presets.yaml b/pyGCodeDecode/examples/data/printer_presets.yaml index f2e4e68..450425f 100644 --- a/pyGCodeDecode/examples/data/printer_presets.yaml +++ b/pyGCodeDecode/examples/data/printer_presets.yaml @@ -13,7 +13,7 @@ anisoprint_a4: vY: 180 vZ: 30 vE: 33 - firmware: MKA + firmware: mka prusa_mini: # general properties @@ -28,22 +28,12 @@ prusa_mini: vY: 180 vZ: 12 vE: 80 - firmware: marlin_jerk - -prusa_mini_klipper: - # general properties - nozzle_diam: 0.4 - filament_diam: 1.75 - # default settings - p_vel: 35 - p_acc: 2500 - jerk: 8 - # axis max speeds - vX: 180 - vY: 180 - vZ: 12 - vE: 80 - firmware: klipper + absolute_position: True + absolute_extrusion: True + volumetric_extrusion: False + initial_position: "first" + units: "SI (mm)" + firmware: prusa debugging: # general properties @@ -58,4 +48,4 @@ debugging: vY: 180 vZ: 12 vE: 80 - firmware: + firmware: ultimaker diff --git a/pyGCodeDecode/examples/data/triangle.gcode b/pyGCodeDecode/examples/data/triangle.gcode new file mode 100644 index 0000000..b4c1876 --- /dev/null +++ b/pyGCodeDecode/examples/data/triangle.gcode @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:599bde84f966821396c3a2eaf8867c2bdf2923c34abbb8416b69abf7c06da0c9 +size 1483 diff --git a/pyGCodeDecode/gcode_interpreter.py b/pyGCodeDecode/gcode_interpreter.py index 9554529..bed83ba 100644 --- a/pyGCodeDecode/gcode_interpreter.py +++ b/pyGCodeDecode/gcode_interpreter.py @@ -1,64 +1,21 @@ """GCode Interpreter Module.""" import importlib.resources -import os -import pathlib -import sys import time -from typing import List, Tuple, Union +from pathlib import Path +from typing import List, Optional, Tuple, Union import numpy as np -import pyvista as pv import yaml -from matplotlib.figure import Figure -from pyGCodeDecode.helpers import custom_print +from pyGCodeDecode.helpers import ProgressBar, custom_print, set_verbosity_level from .planner_block import planner_block +from .result import get_all_result_calculators from .state import state from .state_generator import generate_states from .utils import segment, velocity -last_progress_update: float = 0.0 - - -def update_progress(progress: float, name: str = "Percent") -> None: - """Display or update a console progress bar. - - Args: - progress: float between 0 and 1 for percentage, < 0 represents a 'halt', > 1 represents 100% - name: (string, default = "Percent") customizable name for progress bar - """ - global last_progress_update - - barLength = 10 - status = "" - - # check whether the input is valid - if progress is int: - progress = float(progress) - if not isinstance(progress, float): - progress = 0.0 - status = "error: progress var must be float\r\n" - - # progress outside [0, 1] - if progress < 0.0: - progress = 0.0 - status = "Halt...\r\n" - if progress >= 1.0: - progress = 1.0 - status = "Done...\r\n" - - progress_percent = round(progress * 100, ndigits=1) - - # check whether the progress has changed - if last_progress_update != progress_percent or status != "": - block = int(round(barLength * progress, ndigits=0)) - text = f"\r[{'#' * block + '-' * (barLength - block)}] {progress_percent} % of {name} {status}" - sys.stdout.write(text) - sys.stdout.flush() - last_progress_update = progress_percent - def generate_planner_blocks(states: List[state], firmware=None): """Convert list of states to trajectory repr. by planner blocks. @@ -71,14 +28,27 @@ def generate_planner_blocks(states: List[state], firmware=None): block_list (list[planner_block]) list of all planner blocks to complete travel between all states """ block_list = [] + bar = ProgressBar(name="Planner Blocks") + + colordict = {"infill": "blue", "perimeter": "green"} + last_type = None + for i, this_state in enumerate(states): prev_block = block_list[-1] if len(block_list) > 0 else None # grab prev block from block_list new_block = planner_block(state=this_state, prev_block=prev_block, firmware=firmware) # generate new block + + if this_state.comment is not None: + for key in colordict.keys(): + if key in this_state.comment.lower(): + last_type = key + + new_block.e_type = last_type + if len(new_block.get_segments()) > 0: if new_block.prev_block is not None: new_block.prev_block.next_block = new_block # update nb list block_list.append(new_block) - update_progress((i + 1) / len(states), "Planner Block Generation") + bar.update((i + 1) / len(states)) return block_list @@ -89,7 +59,8 @@ def find_current_segment(path: List[segment], t: float, last_index: int = None, path: (list[segment]) all segments to be searched t: (float) time of search last_index: (int) last found index for optimizing search - keep_position: (bool) keeps position of last segment, use this when working with gaps of no movement between segments + keep_position: (bool) keeps position of last segment, use this when working with + gaps of no movement between segments Returns: segment: (segment) the segment which defines movement at that point in time @@ -133,10 +104,10 @@ def find_current_segment(path: List[segment], t: float, last_index: int = None, # original function untouched # some robustness checks if path[-1].t_end < t: - custom_print("No movement at this time in Path!") + custom_print("No movement at this time in Path!", lvl=1) return None, None elif last_index is None or len(path) - 1 < last_index or path[last_index].t_begin > t: - # custom_print(f"unoptimized Search, last index: {last_index}") + custom_print(f"unoptimized Search, last index: {last_index}", lvl=3) for last_index, segm in enumerate(path): if t >= segm.t_begin and t < segm.t_end: return segm, last_index @@ -167,10 +138,11 @@ class simulation: def __init__( self, - gcode_path: pathlib.Path, + gcode_path: Path, machine_name: str = None, initial_machine_setup: "setup" = None, output_unit_system: str = "SI (mm)", + verbosity_level: Optional[int] = None, ): """Initialize the Simulation of a given G-code with initial machine setup or default machine. @@ -180,9 +152,10 @@ def __init__( Args: gcode_path: (Path) path to GCode - machine name: (string, default = None) name of the default machine to use + machine_name: (string, default = None) name of the default machine to use initial_machine_setup: (setup, default = None) setup instance output_unit_system: (string, default = "SI (mm)") available unit systems: SI, SI (mm) & inch + verbosity_level: (int, default = None) set verbosity level (0: no output, 1: warnings, 2: info, 3: debug) Example: ```python @@ -190,9 +163,10 @@ def __init__( ``` """ simulation_start_time = time.time() - self.last_index = None # used to optimize search in segment list - self.filename = gcode_path + self._last_index = None # used to optimize search in segment list + self.filename = Path(gcode_path) self.firmware = None + set_verbosity_level(verbosity_level) # set output unit system self.available_unit_systems = {"SI": 1e-3, "SI (mm)": 1.0, "inch": 1 / 25.4} @@ -203,7 +177,7 @@ def __init__( # create a printer setup with default values if none was specified if initial_machine_setup is not None: - if machine_name is not None and initial_machine_setup.get_dict()["printer_name"] != machine_name: + if machine_name is not None and initial_machine_setup.get_dict()["printer"] != machine_name: raise ValueError("Both a printer name and a printer setup were specified, but they do not match!") else: pass @@ -212,7 +186,9 @@ def __init__( raise ValueError("Neither a printer name nor a printer setup was specified. At least one is required!") else: custom_print( - "Only a machine name was specified but no full setup. Trying to create a setup from pyGCD's default values..." + "Only a machine name was specified but no full setup. " + "Trying to create a setup from pyGCD's default values...", + lvl=1, ) default_presets_file = importlib.resources.files("pyGCodeDecode").joinpath( "data/default_printer_presets.yaml" @@ -223,361 +199,90 @@ def __init__( ) # SET INITIAL SETTINGS - self.initial_machine_setup = initial_machine_setup.get_dict() - self.check_initial_setup(initial_machine_setup=self.initial_machine_setup) # TODO: move this to setup class - self.firmware = self.initial_machine_setup["firmware"] + self.initial_machine_setup_dict = initial_machine_setup.check_initial_setup() + self.firmware = self.initial_machine_setup_dict["firmware"] self.states: List[state] = generate_states( - filepath=gcode_path, initial_machine_setup=self.initial_machine_setup + filepath=self.filename, initial_machine_setup=self.initial_machine_setup_dict ) custom_print( - f"Simulating \"{self.filename}\" with {self.initial_machine_setup['printer_name']} using the {self.firmware} firmware.\n" + f"Simulating {self.filename} with {self.initial_machine_setup_dict['printer']} using " + f"the {self.firmware} firmware." ) self.blocklist: List[planner_block] = generate_planner_blocks(states=self.states, firmware=self.firmware) self.trajectory_self_correct() - self.print_summary(start_time=simulation_start_time) - - def plot_2d_position( - self, - filepath: pathlib.Path = pathlib.Path("trajectory_2D.png"), - colvar="Velocity", - show_points=False, - colvar_spatial_resolution=1, - dpi=400, - scaled=True, - show=False, - ): - """Plot 2D position (XY plane) with matplotlib (unmaintained).""" - import matplotlib.pyplot as plt - from matplotlib import cm - from matplotlib.collections import LineCollection - - colvar_label = { - "Velocity": "Velocity in mm/s", - "Acceleration": "Acceleration in mm/s^2", - } - - def interp_2D(x, y, cvar, spatial_resolution=1): - segm_length = np.linalg.norm([np.ediff1d(x), np.ediff1d(y)], axis=0) - segm_cvar_delt = np.greater(np.abs(np.ediff1d(cvar)), 0) - segm_interpol = np.r_[ - 0, - np.where(segm_cvar_delt, np.ceil(segm_length / spatial_resolution) + 1, 1), - ] # get nmbr of segments for required resolution, dont interpolate if there is no change - points = np.array([x, y, cvar]).T - points = np.c_[points, segm_interpol] - - # generate intermediate points with set resolution - old_point = None - interpolated = np.zeros((1, 3)) - for point in points: - if old_point is not None: - steps = np.linspace(0, 1, int(point[3]), endpoint=True) - x_i = np.interp(steps, [0, 1], [old_point[0], point[0]]) - y_i = np.interp(steps, [0, 1], [old_point[1], point[1]]) - colvar_i = np.interp(steps, [0, 1], [old_point[2], point[2]]) - interpolated = np.r_[interpolated, np.array([x_i, y_i, colvar_i]).T] - old_point = point - interpolated = np.delete(interpolated, 0, 0) - - return interpolated - - segments = unpack_blocklist(blocklist=self.blocklist) - if colvar == "Velocity": - # get all planned trajectory vertices + color variable - x, y, cvar = [], [], [] - x.append(segments[0].pos_begin.get_vec()[0]) - y.append(segments[0].pos_begin.get_vec()[1]) - cvar.append(segments[0].vel_begin.get_norm()) - - for i, segm in enumerate(segments): - update_progress((i + 1) / len(segments), name="2D Plot Lines") - x.append(segm.pos_end.get_vec()[0]) - y.append(segm.pos_end.get_vec()[1]) - cvar.append(segm.vel_end.get_norm()) - - # interpolate values for smooth coloring - interpolated = interp_2D(x, y, cvar, spatial_resolution=colvar_spatial_resolution) - - x = interpolated[:, 0] - y = interpolated[:, 1] - cvar = interpolated[:, 2] # maybe change interpolation to return tuple? - - # generate point pairs for line collection - point_pairs = [] - for i in np.arange(len(x) - 1): - point_pairs.append([(x[i], y[i]), (x[i + 1], y[i + 1])]) - - # generate collection from pairs - collection = LineCollection(point_pairs) - collection.set_array(cvar) - collection.set_cmap(cm.jet) - - fig = plt.figure() - ax1 = fig.add_subplot(1, 1, 1) - ax1.add_collection(collection) - ax1.autoscale() - plt.colorbar(collection, label=colvar_label[colvar], shrink=0.6, location="right") - else: - x, y = [], [] - x.append(segments[0].pos_begin.get_vec()[0]) - y.append(segments[0].pos_begin.get_vec()[1]) - for i, segm in enumerate(segments): - update_progress((i + 1) / len(segments), name="2D Plot Lines") - x.append(segm.pos_end.get_vec()[0]) - y.append(segm.pos_end.get_vec()[1]) - fig = plt.subplot() - fig.plot(x, y, color="black") - - if show_points: - for i, block in enumerate(self.blocklist): - update_progress(i / len(self.blocklist), name="2D Plot Points") - fig.scatter( - block.get_segments()[-1].pos_end.get_vec()[0], - block.get_segments()[-1].pos_end.get_vec()[1], - color="blue", - marker="x", - ) - - plt.xlabel("x position") - plt.ylabel("y position") - plt.title("2D Position") - if scaled: - plt.axis("scaled") - if filepath is not False: - plt.savefig(filepath, dpi=dpi) - custom_print(f"2D Plot saved:\nπŸ‘‰ {filepath}") - if show: - plt.show() - return fig - plt.close() - - def plot_3d( - self, - extrusion_only: bool = True, - screenshot_path: pathlib.Path = None, - vtk_path: pathlib.Path = None, - mesh: pv.MultiBlock = None, - ) -> pv.MultiBlock: - """3D Plot with PyVista. - - Args: - extrusion_only (bool, optional): Plot only parts where material is extruded. Defaults to True. - screenshot_path (pathlib.Path, optional): Path to screenshot to be saved. Prevents interactive plot. Defaults to None. - vtk_path (pathlib.Path, optional): Path to vtk to be saved. Prevents interactive plot. Defaults to None. - mesh (pv.MultiBlock, optional): A pyvista mesh from a previous run to avoid running the mesh generation again. Defaults to None. + # calculate results + self.results = {} + self.calc_results() + self.calculate_averages() - Returns: - pv.MultiBlock: The mesh used in the plot so it can be used (e.g. in subsequent plots). - """ - # https://docs.pyvista.org/version/stable/api/core/_autosummary/pyvista.polydatafilters.extrude - # https://docs.pyvista.org/version/stable/examples/01-filter/extrude-rotate - - # get all data for plots - segments = unpack_blocklist(blocklist=self.blocklist) - - # mesh generation is skipped, if a mesh is given already - if mesh is None: - mesh = pv.MultiBlock() - - x, y, z, e, vel = [], [], [], [], [] - - for n, segm in enumerate(segments): - update_progress((n + 1) / len(segments), name="3D Plot") - - if (not extrusion_only) or (segm.is_extruding()): - if len(x) == 0: - # append segm begin values to plotting array for first segm - pos_begin_vec = segm.pos_begin.get_vec(withExtrusion=True) - x.append(pos_begin_vec[0]) - y.append(pos_begin_vec[1]) - z.append(pos_begin_vec[2]) - e.append(pos_begin_vec[3]) - vel.append(segm.vel_begin.get_norm()) - - # append segm end values to plotting array - pos_end_vec = segm.pos_end.get_vec(withExtrusion=True) - - x.append(pos_end_vec[0]) - y.append(pos_end_vec[1]) - z.append(pos_end_vec[2]) - e.append(pos_end_vec[3]) - vel.append(segm.vel_end.get_norm()) - - # plot if following segment is not extruding or if it's the last segment - if (extrusion_only and (len(x) > 0 and not segm.is_extruding())) or ( - len(x) > 0 and n == len(segments) - 1 - ): - points_3d = np.column_stack((x, y, z)) - line = pv.lines_from_points(points_3d) - line["velocity"] = vel - tube = line.tube(radius=0.2, n_sides=6) - mesh.append(tube) - x, y, z, e, vel = [], [], [], [], [] # clear plotting array - - mesh = mesh.combine() - - # check wether a display is available or Windows is used - if os.name == "nt" or "DISPLAY" in os.environ: - display_available = True - else: - display_available = False - - # saving a screenshot and an interactive plot aren't possible at the same tim - if screenshot_path is None: - off_screen = False - else: - off_screen = True - - p = pv.Plotter(off_screen=off_screen) - p.add_mesh( - mesh, - scalars="velocity", - smooth_shading=True, - scalar_bar_args={"title": "travel velocity in mm/s"}, - ) - - if vtk_path is not None: - mesh.save(filename=vtk_path) - custom_print(f"VTK saved to:\n{vtk_path}") - if screenshot_path is not None: - if display_available: - p.screenshot(filename=screenshot_path) - custom_print(f"Screenshot saved to:\n{screenshot_path}") - else: - custom_print("Screenshot can not be created without a display!") - - if not off_screen and display_available: - p.show() - - return mesh - - def plot_vel( - self, - axis: Tuple[str] = ("x", "y", "z", "e"), - show: bool = True, - show_planner_blocks: bool = True, - show_segments: bool = False, - show_jv: bool = False, - time_steps: Union[int, str] = "constrained", - filepath: pathlib.Path = None, - dpi: int = 400, - ) -> Figure: - """Plot axis velocity with matplotlib. - - Args: - axis: (tuple(string), default = ("x", "y", "z", "e")) select plot axis - show: (bool, default = True) show plot and return plot figure - show_planner_blocks: (bool, default = True) show planner_blocks as vertical lines - show_segments: (bool, default = False) show segments as vertical lines - show_jv: (bool, default = False) show junction velocity as x - time_steps: (int or string, default = "constrained") number of time steps or constrain plot vertices to segment vertices - filepath: (Path, default = None) save fig as image if filepath is provided - dpi: (int, default = 400) select dpi - - Returns: - (optionally) - fig: (figure) - """ - import matplotlib.pyplot as plt - - axis_dict = {"x": 0, "y": 1, "z": 2, "e": 3} - - segments = unpack_blocklist(blocklist=self.blocklist) # unpack - - # time steps - if type(time_steps) is int: # evenly distributed time steps - times = np.linspace( - 0, - self.blocklist[-1].get_segments()[-1].t_end, - time_steps, - endpoint=False, - ) - elif time_steps == "constrained": # use segment time points as plot constrains - times = [0] - for segm in segments: - times.append(segm.t_end) - else: - raise ValueError("Invalid value for 'time_steps', either use Integer or 'constrained' as argument.") - - # gathering values - pos = [[], [], [], []] - vel = [[], [], [], []] - abs = [] # initialize value arrays - index_saved = 0 - - for i, t in enumerate(times): - segm, index_saved = find_current_segment(path=segments, t=t, last_index=index_saved, keep_position=True) - - tmp_vel = segm.get_velocity(t=t).get_vec(withExtrusion=True) - tmp_pos = segm.get_position(t=t).get_vec(withExtrusion=True) - for ax in axis: - pos[axis_dict[ax]].append(tmp_pos[axis_dict[ax]]) - vel[axis_dict[ax]].append(tmp_vel[axis_dict[ax]]) - - abs.append(np.linalg.norm(tmp_vel[:3])) - update_progress((i + 1) / len(times), name="Velocity Plot") - - fig, ax1 = plt.subplots() - ax2 = ax1.twinx() - - # plot JD-Limits - for block in self.blocklist: - # planner blocks vertical line plot - if show_planner_blocks: - ax1.axvline(x=block.get_segments()[-1].t_end, color="black", lw=0.5) - - # segments vertical line plot - if show_segments: - for segm in block.get_segments(): - ax1.axvline(x=segm.t_end, color="green", lw=0.25) - - if show_jv: - # absolute JD Marker - absJD = np.linalg.norm([block.JD[0], block.JD[1], block.JD[2]]) - ax1.scatter(x=block.get_segments()[-1].t_end, y=absJD, color="red", marker="x") - for ax in axis: - ax1.scatter( - x=block.get_segments()[-1].t_end, - y=block.JD[axis_dict[ax]], - marker="x", - color="black", - lw=0.5, - ) + self.print_summary(start_time=simulation_start_time) - # plot all axis in velocity and position - for ax in axis: - ax1.plot(times, vel[axis_dict[ax]], label=ax) # velocity - ax2.plot(times, pos[axis_dict[ax]], linestyle="--") # position w/ extrusion - # if not ax == "e": ax2.plot(times,pos[axis_dict[ax]],linestyle="--") #position ignoring extrusion - ax1.plot(times, abs, color="black", label="abs") # absolute velocity - - ax1.set_xlabel("time in s") - ax1.set_ylabel("velocity in mm/s") - ax2.set_ylabel("position in mm") - ax1.legend(loc="lower left") - plt.title("Velocity and Position over Time") - if filepath is not None: - plt.savefig(filepath, dpi=dpi) - if show: - plt.show() - plt.close() - return fig + def __getattr__(self, name): + """Get result by name.""" + if name in self.results: + return self.results[name] def trajectory_self_correct(self): """Self correct all blocks in the blocklist with self_correction() method.""" n_max = len(self.blocklist) - last_progress_update = 0 + bar = ProgressBar(name="Block Correction") for n, block in enumerate(self.blocklist): progress = round(n / n_max, ndigits=3) - if progress > last_progress_update: - update_progress((n + 1) / len(self.blocklist), name="Block Correction") - last_progress_update = progress + if progress > bar.last_progress_update: + bar.update((n + 1) / len(self.blocklist)) + bar.last_progress_update = progress block.self_correction() + bar.update(1.0) + + def calc_results(self): + """Calculate the results.""" + calculators = get_all_result_calculators() + + for pb in self.blocklist: + pb.calc_results(*calculators) + + def calculate_averages(self): + """Calculate averages for averageable results.""" + + def spatial_average(calculator): + total_dist = 0 + glob_result = 0 + for segm in unpack_blocklist(self.blocklist): + len = segm.get_segm_len() + segm_result = segm.get_result(calculator.name + "_savg") + if segm.is_extruding(): + total_dist += len + glob_result += segm_result * len + if total_dist > 0: + return glob_result / total_dist + + def time_average(calculator): + total_time = 0 + glob_result = 0 + for segm in unpack_blocklist(self.blocklist): + duration = segm.get_segm_duration() + segm_result = segm.get_result(calculator.name + "_tavg") + if segm.is_extruding(): + total_time += duration + glob_result += segm_result * duration + if total_time > 0: + return glob_result / total_time + + calculators = get_all_result_calculators() + for calculator in calculators: + if hasattr(calculator, "avgs") and isinstance(calculator.avgs, (list, tuple)): + for avg in calculator.avgs: + if avg == "_savg": + self.results[calculator.name + "_savg"] = spatial_average(calculator) + elif avg == "_tavg": + self.results[calculator.name + "_tavg"] = time_average(calculator) + else: + raise ValueError(f"Unknown average type: {avg} for {calculator.name}") def get_values(self, t: float, output_unit_system: str = None) -> Tuple[List[float]]: """Return unit system scaled values for vel and pos. @@ -592,7 +297,7 @@ def get_values(self, t: float, output_unit_system: str = None) -> Tuple[List[flo list: [pos_x, pos_y, pos_z, pos_e] position """ segments = unpack_blocklist(blocklist=self.blocklist) - segm, self.last_index = find_current_segment(path=segments, t=t, last_index=self.last_index) + segm, self._last_index = find_current_segment(path=segments, t=t, last_index=self._last_index) tmp_vel = segm.get_velocity(t=t).get_vec(withExtrusion=True) tmp_pos = segm.get_position(t=t).get_vec(withExtrusion=True) @@ -604,7 +309,7 @@ def get_values(self, t: float, output_unit_system: str = None) -> Tuple[List[flo return tmp_vel, tmp_pos - def get_width(self, t: float, extrusion_h: float, filament_dia: float): + def get_width(self, t: float, extrusion_h: float, filament_dia: Optional[float] = None) -> float: """Return the extrusion width for a certain extrusion height at time. Args: @@ -615,62 +320,20 @@ def get_width(self, t: float, extrusion_h: float, filament_dia: float): Returns: float: width """ + filament_dia = self.initial_machine_setup_dict["filament_diam"] if filament_dia is None else filament_dia + curr_val = self.get_values(t=t) feed_rate = np.linalg.norm(curr_val[0][:3]) # calculate feed rate at current time flow_rate = curr_val[0][3] # get extrusion rate at current time - filament_cross_sec = (np.pi * filament_dia**2) / 4 # calculate cross area of filament - + filament_cross_sec = np.pi * (filament_dia / 2) ** 2 # calculate cross area of filament width = ( (flow_rate * filament_cross_sec) / (extrusion_h * feed_rate) if feed_rate > 0 else 0 ) # calculate width, zero if no movement. return width - def check_initial_setup(self, initial_machine_setup): - """Check the printer Dict for typos or missing parameters and raise errors if invalid. - - Args: - initial_machine_setup: (dict) initial machine setup dictionary - """ - req_keys = [ - "p_vel", - "p_acc", - "jerk", - "vX", - "vY", - "vZ", - "vE", - "X", - "Y", - "Z", - "E", - "printer_name", - "firmware", - ] - optional_keys = [ - "layer_cue", - "nozzle_diam", - "filament_diam", - ] - - valid_keys = req_keys + optional_keys - - # check if all provided keys are valid - for key in initial_machine_setup: - if key not in valid_keys: - raise ValueError( - f'Invalid Key: "{key}" in Setup Dictionary, check for typos. Valid keys are: {valid_keys}' - ) - - # check if every required key is proivded - for key in req_keys: - if key not in initial_machine_setup: - raise ValueError( - f'Missing Key: "{key}" is not provided in Setup Dictionary, check for typos. Required keys are: {req_keys}' - ) - def print_summary(self, start_time: float): """Print simulation summary to console. @@ -678,22 +341,25 @@ def print_summary(self, start_time: float): start_time (float): time when the simulation run was started """ custom_print( - f" >> pyGCodeDecode extracted {len(self.states)} states from {self.filename} and generated {len(self.blocklist)} planner blocks.\n" - f"Estimated time to travel all states with provided printer settings is {self.blocklist[-1].get_segments()[-1].t_end:.2f} seconds.\n" - f"The Simulation took {(time.time()-start_time):.2f} s." + f"βœ… Simulation finished: pyGCodeDecode extracted {len(self.states)} states from {self.filename}" + f" and generated {len(self.blocklist)} planner blocks.\n" + f"Estimated time to travel all states with provided " + f"printer settings is {self.blocklist[-1].get_segments()[-1].t_end:.2f} seconds.\n" + f"The Simulation took {(time.time()-start_time):.2f} s of computation time." ) def refresh(self, new_state_list: List[state] = None): """Refresh simulation. Either through new state list or by rerunning the self.states as input. Args: - new_state_list: (list[state], default = None) new list of states, if None is provided, existing states get resimulated + new_state_list: (list[state], default = None) new list of states, + if None is provided, existing states get resimulated """ if new_state_list is not None: self.states = new_state_list self.blocklist: List[planner_block] = generate_planner_blocks( - states=self.states, firmware=self.initial_machine_setup["firmware"] + states=self.states, firmware=self.initial_machine_setup_dict["firmware"] ) self.trajectory_self_correct() @@ -744,11 +410,11 @@ def extrusion_max_vel(self, output_unit_system: str = None) -> np.float64: return scaling * max_vel - def save_summary(self, filepath: Union[pathlib.Path, str]): + def save_summary(self, filepath: Union[Path, str]): """Save summary to .yaml file. Args: - filepath (pathlib.Path | str): path to summary file + filepath (Path | str): path to summary file Saved data keys: - filename (string, filename) @@ -772,12 +438,12 @@ def save_summary(self, filepath: Union[pathlib.Path, str]): } # create directory if necessary - pathlib.Path(filepath).parent.mkdir(parents=True, exist_ok=True) + Path(filepath).parent.mkdir(parents=True, exist_ok=True) with open(file=filepath, mode="w") as file: yaml.dump(data=summary, stream=file) - custom_print(f"πŸ’Ύ Summary written to:\nπŸ‘‰ {str(filepath)}") + custom_print(f"πŸ’Ύ Summary written to πŸ‘‰ {str(filepath)}") def get_scaling_factor(self, output_unit_system: str = None) -> float: """Get a scaling factor to convert lengths from mm to another supported unit system. @@ -803,37 +469,44 @@ def __init__( self, presets_file: str, printer: str = None, - layer_cue: str = None, - ) -> None: - """Create simulation setup. + verbosity_level: Optional[int] = None, + **kwargs, + ): + """Initialize the setup for the printing simulation. Args: - presets_file: (string) choose setup yaml file with printer presets - printer: (string) select printer from preset file - layer_cue: (string) set slicer specific layer change cue from comment + presets_file (str): Path to the YAML file containing printer presets. + printer (str, optional): Name of the printer to select from the preset file. Defaults to None. + verbosity_level (int, optional): Verbosity level for logging (0: no output, 1: warnings, 2: info, 3: debug). Defaults to None. + **kwargs: Additional properties to set or override in the setup. + + Raises: + ValueError: If multiple printers are found in the preset file but none is selected. """ - # the input unit system is only implemented for 'set_initial_position'. - # Regardless, the class has this attribute so it's more similar to the simulation class. + set_verbosity_level(verbosity_level) self.available_unit_systems = {"SI": 1e3, "SI (mm)": 1.0, "inch": 25.4} self.input_unit_system = "SI (mm)" - self.initial_position = { - "X": 0, - "Y": 0, - "Z": 0, - "E": 0, - } # default initial pos is zero - self.setup_dict = self.load_setup(presets_file) + # load the setup + self.load_setup(presets_file, printer=printer) + + # set additional properties provided as keyword arguments + self.set_property(kwargs) - self.filename = presets_file - self.printer_select = printer - self.layer_cue = layer_cue + def __getattr__(self, name): + """Access to setup_dict content.""" + if name in self.setup_dict: + return self.setup_dict[name] + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") - if self.printer_select is not None: - self.select_printer(printer_name=self.printer_select) - self.firmware = self.get_dict()["firmware"] + def __setattr__(self, name, value): + """Set setup_dict keys.""" + if name in ["setup_dict", "filename", "available_unit_systems", "input_unit_system"]: + super().__setattr__(name, value) + else: + self.setup_dict[name] = value - def load_setup(self, filepath): + def load_setup(self, filepath, printer=None): """Load setup from file. Args: @@ -845,24 +518,86 @@ def load_setup(self, filepath): file = open(file=filepath) setup_dict = yaml.load(file, Loader=Loader) - return setup_dict + if printer: + self.setup_dict = setup_dict[printer] + self.printer = printer + else: + printers_available = [printer for printer in setup_dict] - def select_printer(self, printer_name): - """Select printer by name. + if len(printers_available) == 1: + printer = printers_available[0] + self.setup_dict = setup_dict[printer] + self.printer = printer + custom_print(f"Automatically selected the '{printer}' printer in the setup file {filepath}.", lvl=2) + else: + raise ValueError("Multiple printers found but none has been selected.") - Args: - printer_name: (string) select printer by name - """ - if printer_name not in self.setup_dict: - raise ValueError(f"Selected Printer {self.printer_select} not found in setup file: {self.filename}.") + # parse initial position if set via config + if "initial_position" in self.setup_dict: + self.set_initial_position(self.setup_dict["initial_position"]) else: - self.printer_select = printer_name + self.set_initial_position( + { + "X": 0, + "Y": 0, + "Z": 0, + "E": 0, + } + ) # default initial pos is zero + + def check_initial_setup(self): + """Check the printer Dict for typos or missing parameters and raise errors if invalid.""" + req_keys = [ + "p_vel", + "p_acc", + "jerk", + "vX", + "vY", + "vZ", + "vE", + "X", + "Y", + "Z", + "E", + "printer", + "firmware", + ] + optional_keys = [ + "layer_cue", + "nozzle_diam", + "filament_diam", + "volumetric_extrusion", + "absolute_position", + "absolute_extrusion", + "initial_position", + "units", + ] + + valid_keys = req_keys + optional_keys + initial_machine_setup = self.setup_dict + + # check if all provided keys are valid + for key in initial_machine_setup: + if key not in valid_keys: + raise ValueError( + f"Invalid Key: '{key}' in Setup Dictionary, check for typos. Valid keys are: {valid_keys}" + ) + + # check if every required key is proivded + for key in req_keys: + if key not in initial_machine_setup: + raise ValueError( + f"Missing Key: '{key}' is not provided in Setup Dictionary," + f" check for typos. Required keys are: {req_keys}" + ) + return initial_machine_setup def set_initial_position(self, initial_position: Union[tuple, dict], input_unit_system: str = None): """Set initial Position. Args: - initial_position: (tuple or dict) set initial position as tuple of len(4) or dictionary with keys: {X, Y, Z, E}. + initial_position: (tuple or dict) set initial position as tuple of len(4) + or dictionary with keys: {X, Y, Z, E} or "first" to use first occuring absolute position in GCode. input_unit_system (str, optional): Wanted input unit system. Uses the one specified for the setup if None is specified. @@ -870,6 +605,7 @@ def set_initial_position(self, initial_position: Union[tuple, dict], input_unit_ ```python setup.set_initial_position((1, 2, 3, 4)) setup.set_initial_position({"X": 1, "Y": 2, "Z": 3, "E": 4}) + setup.set_initial_position("first") # use first GCode position ``` """ @@ -877,19 +613,24 @@ def set_initial_position(self, initial_position: Union[tuple, dict], input_unit_ if isinstance(initial_position, dict) and all(key in initial_position for key in ["X", "Y", "Z", "E"]): for key in initial_position: - self.initial_position[key] = scaling * initial_position[key] + self.setup_dict[key] = scaling * initial_position[key] elif isinstance(initial_position, tuple) and len(initial_position) == 4: - self.initial_position = { - "X": scaling * initial_position[0], - "Y": scaling * initial_position[1], - "Z": scaling * initial_position[2], - "E": scaling * initial_position[3], - } + self.setup_dict.update( + { + "X": scaling * initial_position[0], + "Y": scaling * initial_position[1], + "Z": scaling * initial_position[2], + "E": scaling * initial_position[3], + } + ) + elif initial_position == "first": # use first GCode position + self.setup_dict.update({"X": None, "Y": None, "Z": None, "E": None}) + custom_print("Initial position set to first GCode position.", lvl=3) else: raise ValueError("Set initial position through dict with keys: {X, Y, Z, E} or as tuple with length 4.") def set_property(self, property_dict: dict): - """Overwrite or add a property to the printer dictionary. Printer has to be selected through select_printer() beforehand. + """Overwrite or add a property to the printer dictionary. Args: property_dict: (dict) set or add property to the setup @@ -900,10 +641,7 @@ def set_property(self, property_dict: dict): ``` """ - if self.printer_select is not None: - self.setup_dict[self.printer_select].update(property_dict) - else: - raise ValueError("No printer is selected. Select printer through select_printer() beforehand.") + self.setup_dict.update(property_dict) def get_dict(self) -> dict: """Return the setup for the selected printer. @@ -911,12 +649,7 @@ def get_dict(self) -> dict: Returns: return_dict: (dict) setup dictionary """ - return_dict = self.setup_dict[self.printer_select] # create dict - return_dict.update(self.initial_position) # add initial position - if self.layer_cue is not None: - return_dict.update({"layer_cue": self.layer_cue}) # add layer cue - return_dict.update({"printer_name": self.printer_select}) # add printer name - + return_dict = self.setup_dict return return_dict def get_scaling_factor(self, input_unit_system: str = None) -> float: diff --git a/pyGCodeDecode/helpers.py b/pyGCodeDecode/helpers.py index 595e185..3932f3f 100644 --- a/pyGCodeDecode/helpers.py +++ b/pyGCodeDecode/helpers.py @@ -1,6 +1,7 @@ """Helper functions.""" import sys +from typing import Optional # global flags # check if program is running in ABAQUS-Python @@ -9,9 +10,50 @@ else: FLAG_USING_ABAQUS = False +# global verbosity level +VERBOSITY_LEVEL = 2 # default to INFO + +# global progress bar state +_active_progress_bar = None + + +# Define verbosity levels for custom_print +# _levels = { +# 3: "[ DEBUG ]", +# 2: "[ INFO ]", +# 1: "[WARNING]", +# } + +_levels = { + 3: "[DEBU]", + 2: "[INFO]", + 1: "[WARN]", +} + + +def set_verbosity_level(level: Optional[int]) -> None: + """Set the global verbosity level.""" + global VERBOSITY_LEVEL + if level is not None: + VERBOSITY_LEVEL = level + + +def get_verbosity_level() -> int: + """Get the current global verbosity level.""" + return VERBOSITY_LEVEL + + +def custom_print(*args, lvl=2, **kwargs) -> None: + """Sanitize outputs for ABAQUS and print them if the log level is high enough. Takes all arguments for print. + + Args: + *args: arguments to be printed + lvl: verbosity level of the print (1 = WARNING, 2 = INFO, 3 = DEBUG) + **kwargs: keyword arguments to be passed to print + + """ + # global _active_progress_bar -def custom_print(*args, **kwargs) -> None: - """Sanitize outputs for ABAQUS and print them. Takes regular arguments for print.""" sanitized_args = [] if FLAG_USING_ABAQUS: # remove non-ascii characters like emojis as ABAQUS can't handle them @@ -20,4 +62,107 @@ def custom_print(*args, **kwargs) -> None: else: sanitized_args = args - print(*sanitized_args, **kwargs) + # print with verbosity level + if lvl <= VERBOSITY_LEVEL: + # If there's an active progress bar, clear the line first + if _active_progress_bar is not None: + # Clear the current line + sys.stdout.write("\r" + " " * 80 + "\r") + sys.stdout.flush() + + prefix = _levels.get(lvl, "") + + # Process arguments to handle newline alignment + processed_args = [] + for arg in sanitized_args: + if isinstance(arg, str) and "\n" in arg: + # Split by newlines and add appropriate spacing after each newline + lines = arg.split("\n") + # Join with newline followed by spacing to align with prefix + spacing = " " * len(prefix + " ") # +1 for the space after prefix + processed_arg = ("\n" + spacing).join(lines) + processed_args.append(processed_arg) + else: + processed_args.append(arg) + + # Print the message + if _active_progress_bar is not None: + # When progress bar is active, print without extra newlines + print(prefix, *processed_args, **kwargs) + # Redraw the progress bar immediately + _active_progress_bar._redraw_current_state() + else: + # Normal print when no progress bar is active + print(prefix, *processed_args, **kwargs) + + +class ProgressBar: + """A simple progress bar for the console.""" + + def __init__(self, name: str = "Percent", barLength: int = 4, verbosity_level: int = 2): + """Initialize a progress bar.""" + self.name = name + self.barLength = barLength + self.last_progress_update = -1 + self.last_text = "" # Store the last progress bar text + self.verbosity_level = verbosity_level + + def _redraw_current_state(self) -> None: + """Redraw the current progress bar state.""" + if self.last_text and VERBOSITY_LEVEL >= 2: + # Remove any existing \r from the stored text and add it fresh + clean_text = self.last_text.lstrip("\r") + sys.stdout.write("\r" + clean_text) + sys.stdout.flush() + + def update(self, progress: float) -> None: + """Display or update a console progress bar. + + Args: + progress: float between 0 and 1 for percentage, < 0 represents a 'halt', > 1 represents 100% + """ + global _active_progress_bar + + barLength = self.barLength + status = "" + + if VERBOSITY_LEVEL >= self.verbosity_level: # only print if verbosity level is high enough + # Register this progress bar as active + _active_progress_bar = self + + # check whether the input is valid + if progress is int: + progress = float(progress) + if not isinstance(progress, float): + progress = 0.0 + status = "error: progress var must be float\r\n" + + # progress outside [0, 1] + if progress < 0.0: + progress = 0.0 + status = "- Waiting \r\n" + if progress >= 1.0: + progress = 1.0 + status = "- Done βœ…" + + progress_percent = round(progress * 100, ndigits=1) + + # check whether the progress has changed + if self.last_progress_update != progress_percent or status != "": + block = int(round(barLength * progress, ndigits=0)) + if progress < 1.0: + text = f"\r[{'#' * block + '-' * (barLength - block)}] {progress_percent} % of {self.name} {status}" + else: + text = f"\r{_levels.get(self.verbosity_level, '')} βœ… Done with {self.name}" + # text = f"\r[{'#' * block + '-' * (barLength - block)}] βœ… Done with {self.name}" + self.last_text = text + sys.stdout.write(text) + sys.stdout.flush() + self.last_progress_update = progress_percent + + # If we're done, add a newline and unregister + if progress >= 1.0: + sys.stdout.write("\n") + sys.stdout.flush() + self.last_text = "" + _active_progress_bar = None diff --git a/pyGCodeDecode/junction_handling.py b/pyGCodeDecode/junction_handling.py index ddc7371..3dbd408 100644 --- a/pyGCodeDecode/junction_handling.py +++ b/pyGCodeDecode/junction_handling.py @@ -1,9 +1,12 @@ -"""Junction handling module.""" +"""Junction handling module for calculating the velocity at junctions.""" -import math +import inspect +import sys import numpy as np +from pyGCodeDecode.helpers import custom_print + from .state import state from .utils import velocity @@ -11,6 +14,18 @@ class junction_handling: """Junction handling super class.""" + def __init__(self, state_A: state, state_B: state): + """Initialize the junction handling. + + Args: + state_A: (state) start state + state_B: (state) end state + """ + self.state_A = state_A + self.state_B = state_B + self.target_vel = self.connect_state(state_A=state_A, state_B=state_B) + self.vel_next = self._calc_vel_next() + def connect_state(self, state_A: state, state_B: state): """ Connect two states and generates the velocity for the move from state_A to state_B. @@ -37,13 +52,14 @@ def connect_state(self, state_A: state, state_B: state): target_vel = velocity(state_B.state_p_settings.speed * travel_direction) return target_vel - def calc_vel_next(self): + def _calc_vel_next(self): """Return the target velocity for the following move.""" next_next_state = self.state_B.next_state if self.state_B.next_state is not None else self.state_B while True: if ( next_next_state.next_state is None - or self.state_B.state_position.get_t_distance(next_next_state.state_position, withExtrusion=True) > 0 + or self.state_B.state_position != next_next_state.state_position + # or self.state_B.state_position.get_t_distance(next_next_state.state_position, withExtrusion=True) > 0 # inefficient check ): vel_next = self.connect_state( state_A=self.state_B, state_B=next_next_state @@ -57,18 +73,6 @@ def get_target_vel(self): """Return target velocity.""" return self.target_vel - def __init__(self, state_A: state, state_B: state): - """Initialize the junction handling. - - Args: - state_A: (state) start state - state_B: (state) end state - """ - self.state_A = state_A - self.state_B = state_B - self.target_vel = self.connect_state(state_A=state_A, state_B=state_B) - self.vel_next = self.calc_vel_next() - def get_junction_vel(self): """Return default junction velocity of zero. @@ -78,75 +82,111 @@ def get_junction_vel(self): return 0 -class junction_handling_marlin_jd(junction_handling): - """Marlin specific junction handling with Junction Deviation.""" +class prusa(junction_handling): + """Prusa specific classic jerk junction handling (validated on Prusa Mini). + + **Code reference:** + [Prusa-Firmware-Buddy/lib/Marlin/Marlin/src/module/planner.cpp](https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/818d812f954802903ea0ff39bf44376fb0b35dd2/lib/Marlin/Marlin/src/module/planner.cpp#L1951) + + ```cpp + // ... + // Factor to multiply the previous / current nominal velocities to get componentwise limited velocities. + float v_factor = 1; + limited = 0; + + // The junction velocity will be shared between successive segments. Limit the junction velocity to their minimum. + // Pick the smaller of the nominal speeds. Higher speed shall not be achieved at the junction during coasting. + vmax_junction = _MIN(block->nominal_speed, previous_nominal_speed); + + // Now limit the jerk in all axes. + const float smaller_speed_factor = vmax_junction / previous_nominal_speed; + #if HAS_LINEAR_E_JERK + LOOP_XYZ(axis) + #else + LOOP_XYZE(axis) + #endif + { + // Limit an axis. We have to differentiate: coasting, reversal of an axis, full stop. + float v_exit = previous_speed[axis] * smaller_speed_factor, + v_entry = current_speed[axis]; + if (limited) { + v_exit *= v_factor; + v_entry *= v_factor; + } + + // Calculate jerk depending on whether the axis is coasting in the same direction or reversing. + const float jerk = (v_exit > v_entry) + ? // coasting axis reversal + ( (v_entry > 0 || v_exit < 0) ? (v_exit - v_entry) : _MAX(v_exit, -v_entry) ) + : // v_exit <= v_entry coasting axis reversal + ( (v_entry < 0 || v_exit > 0) ? (v_entry - v_exit) : _MAX(-v_exit, v_entry) ); + + if (jerk > settings.max_jerk[axis]) { + v_factor *= settings.max_jerk[axis] / jerk; + ++limited; + } + } + if (limited) vmax_junction *= v_factor; + // Now the transition velocity is known, which maximizes the shared exit / entry velocity while + // respecting the jerk factors, it may be possible, that applying separate safe exit / entry velocities will achieve faster prints. + const float vmax_junction_threshold = vmax_junction * 0.99f; + if (previous_safe_speed > vmax_junction_threshold && safe_speed > vmax_junction_threshold) + vmax_junction = safe_speed; + } + // ... + ``` + """ - def calc_JD(self, vel_0: velocity, vel_1: velocity, p_settings: state.p_settings): + def __init__(self, state_A: state, state_B: state): + """Marlin classic jerk specific junction velocity calculation. + + Args: + state_A: (state) start state + state_B: (state) end state """ - Calculate junction deviation velocity from 2 velocitys. + super().__init__(state_A, state_B) - **Reference:** + self.calc_j_vel() - [https://onehossshay.wordpress.com/2011/09/24/improving_grbl_cornering_algorithm/](https://onehossshay.wordpress.com/2011/09/24/improving_grbl_cornering_algorithm/) - [http://blog.kyneticcnc.com/2018/10/computing-junction-deviation-for-marlin.html](http://blog.kyneticcnc.com/2018/10/computing-junction-deviation-for-marlin.html) + def calc_j_vel(self): + """Calculate the junction velocity.""" + vel_0 = self.target_vel + vel_1 = self.vel_next + self.jerk = self.state_B.state_p_settings.jerk + v_max_junction = min(vel_0.get_norm(), vel_1.get_norm()) + smaller_speed_factor = v_max_junction / vel_0.get_norm() if v_max_junction > 0 else 0 - Args: - vel_0: (velocity) entry - vel_1: (velocity) exit - p_settings: (state.p_settings) print settings + v_factor = 1.0 + limited = False - Returns: - velocity: (float) velocity abs value - """ - # Junction deviation settings - JD_acc = p_settings.p_acc - if p_settings.jerk == 0: - return 0 - JD_delta = 0.4 * p_settings.jerk * p_settings.jerk / JD_acc # [2] - JD_minAngle = 18 - JD_maxAngle = 180 - 18 - vel_0_vec = vel_0.get_vec() - vel_1_vec = vel_1.get_vec() - if vel_0.get_norm() == 0 or vel_1.get_norm() == 0: - return 0 - # calculate junction angle - JD_cos_theta = np.dot(-np.asarray(vel_0_vec), np.asarray(vel_1_vec)) / ( - np.linalg.norm(vel_0_vec) * np.linalg.norm(vel_1_vec) - ) # cos of theta, theta: small angle between velocity vectors - if JD_cos_theta < 1: # catch numerical errors where cos theta is slightly larger than one - JD_sin_theta_half = np.sqrt((1 - JD_cos_theta) / 2) - else: - JD_sin_theta_half = 0 - if JD_sin_theta_half < np.sin(JD_maxAngle * np.pi / (2 * 180)): # smaller than max angle - if JD_sin_theta_half > np.sin( - JD_minAngle * np.pi / (2 * 180) - ): # and larger than min angle --> apply Junction Deviation Calculation - # calculate scalar junction velocity - JD_Radius = JD_delta * JD_sin_theta_half / (1 - JD_sin_theta_half) - JD_velocity_scalar = np.sqrt(JD_acc * JD_Radius) + for axis in range(4): # Include extrusion axis + v_exit = vel_0.get_vec(withExtrusion=True)[axis] * smaller_speed_factor + v_entry = vel_1.get_vec(withExtrusion=True)[axis] - # return JD_velocity_scalar if JD_velocity_scalar < vel_0.get_norm() else vel_0.get_norm() - return JD_velocity_scalar if JD_velocity_scalar < p_settings.speed else p_settings.speed + if limited: + v_exit *= v_factor + v_entry *= v_factor + + # Calculate jerk depending on whether the axis is coasting in the same direction or reversing + if v_exit > v_entry: + # coasting: (v_entry > 0 or v_exit < 0), axis reversal: else + jerk = (v_exit - v_entry) if (v_entry > 0 or v_exit < 0) else max(v_exit, -v_entry) else: - return 0 # angle smaller than min angle, stop completely - else: - return p_settings.speed # angle larger than max angle, full speed pass + # coasting: (v_entry < 0 or v_exit > 0), axis reversal: else + jerk = (v_entry - v_exit) if (v_entry < 0 or v_exit > 0) else max(-v_exit, v_entry) - def __init__(self, state_A: state, state_B: state): - """Marlin specific junction velocity calculation with Junction Deviation. + if jerk > self.jerk: + v_factor *= self.jerk / jerk + limited = True - Args: - state_A: (state) start state - state_B: (state) end state - """ - super().__init__(state_A, state_B) - self.junction_vel = self.calc_JD( - vel_0=self.target_vel, vel_1=self.vel_next, p_settings=self.state_B.state_p_settings - ) + if limited: + v_max_junction *= v_factor + + self.junction_vel = v_max_junction def get_junction_vel(self): - """Return junction velocity. + """Return the calculated junction velocity. Returns: junction_vel: (float) junction velocity @@ -154,10 +194,25 @@ def get_junction_vel(self): return self.junction_vel -class junction_handling_marlin_jerk(junction_handling): +class marlin(junction_handling): """Marlin classic jerk specific junction handling. - **Reference** + **Code reference:** + [Marlin/src/module/planner.cpp](https://github.com/MarlinFirmware/Marlin/blob/8ec9c379405bb9962aff170d305ddd0725bd64e2/Marlin/src/module/planner.cpp#L2762) + ```cpp + // ... + float v_factor = 1.0f; + LOOP_LOGICAL_AXES(i) { + // Jerk is the per-axis velocity difference. + const float jerk = ABS(speed_diff[i]), maxj = max_j[i]; + if (jerk * v_factor > maxj) v_factor = maxj / jerk; + } + vmax_junction_sqr = sq(vmax_junction * v_factor); + // ... + ``` + """ + + """" **Reference** [https://github.com/MarlinFirmware/Marlin/pull/8887](https://github.com/MarlinFirmware/Marlin/pull/8887) [https://github.com/MarlinFirmware/Marlin/pull/8888](https://github.com/MarlinFirmware/Marlin/pull/8888) [https://github.com/MarlinFirmware/Marlin/issues/367#issuecomment-12505768](https://github.com/MarlinFirmware/Marlin/issues/367#issuecomment-12505768) @@ -178,14 +233,18 @@ def calc_j_vel(self): """Calculate the junction velocity.""" vel_0 = self.target_vel vel_1 = self.vel_next - self.jerk = self.state_B.state_p_settings.jerk * 2 + self.jerk = self.state_B.state_p_settings.jerk vel_diff = vel_0 - vel_1 - jerk_move = vel_diff.get_norm() - scale = jerk_move / self.jerk if self.jerk > 0 else 0 - if scale >= 1: - self.junction_vel = (vel_0 / scale).get_norm() + scale = 1.0 + for axx in range(4): + ax_jerk = abs(vel_diff.get_vec(withExtrusion=True)[axx]) + if ax_jerk * scale > self.jerk: + scale = self.jerk / ax_jerk + + if scale < 1: + self.junction_vel = (vel_0 * scale).get_norm() else: self.junction_vel = vel_0.get_norm() @@ -198,72 +257,98 @@ def get_junction_vel(self): return self.junction_vel -class junction_handling_klipper(junction_handling): - """Klipper specific junction handling. - - - similar junction deviation calc - - corner vel set by: square_corner_velocity - end_velocity^2 = start_velocity^2 + 2*accel*move_distance - for 90deg turn - - todo: smoothed look ahead - - **Reference:** - [https://www.klipper3d.org/Kinematics.html](https://www.klipper3d.org/Kinematics.html) - [https://github.com/Klipper3d/klipper/blob/ea2f6bc0f544132738c7f052ffcc586fa884a19a/klippy/toolhead.py](https://github.com/Klipper3d/klipper/blob/ea2f6bc0f544132738c7f052ffcc586fa884a19a/klippy/toolhead.py) +class ultimaker(junction_handling): + """Ultimaker specific junction handling. + + **Code reference:** + [UM2.1-Firmware/Marlin/planner.cpp](https://github.com/Ultimaker/UM2.1-Firmware/blob/f6e69344c00d7f300dace730990652ba614a2105/Marlin/planner.cpp#L840) + ```cpp + // ... + float vmax_junction = max_xy_jerk/2; + float vmax_junction_factor = 1.0; + if(fabs(current_speed[Z_AXIS]) > max_z_jerk/2) + vmax_junction = min(vmax_junction, max_z_jerk/2); + if(fabs(current_speed[E_AXIS]) > max_e_jerk/2) + vmax_junction = min(vmax_junction, max_e_jerk/2); + vmax_junction = min(vmax_junction, block->nominal_speed); + float safe_speed = vmax_junction; + + if ((moves_queued > 1) && (previous_nominal_speed > 0.0001)) { + float xy_jerk = sqrt(square(current_speed[X_AXIS]-previous_speed[X_AXIS])+square(current_speed[Y_AXIS]-previous_speed[Y_AXIS])); + // if((fabs(previous_speed[X_AXIS]) > 0.0001) || (fabs(previous_speed[Y_AXIS]) > 0.0001)) { + vmax_junction = block->nominal_speed; + // } + if (xy_jerk > max_xy_jerk) { + vmax_junction_factor = (max_xy_jerk / xy_jerk); + } + if(fabs(current_speed[Z_AXIS] - previous_speed[Z_AXIS]) > max_z_jerk) { + vmax_junction_factor= min(vmax_junction_factor, (max_z_jerk/fabs(current_speed[Z_AXIS] - previous_speed[Z_AXIS]))); + } + if(fabs(current_speed[E_AXIS] - previous_speed[E_AXIS]) > max_e_jerk) { + vmax_junction_factor = min(vmax_junction_factor, (max_e_jerk/fabs(current_speed[E_AXIS] - previous_speed[E_AXIS]))); + } + vmax_junction = min(previous_nominal_speed, vmax_junction * vmax_junction_factor); // Limit speed to max previous speed + } + // Max entry speed of this block equals the max exit speed of the previous block. + block->max_entry_speed = vmax_junction; + // ... + ``` """ def __init__(self, state_A: state, state_B: state): - """Klipper specific junction velocity calculation. + """Ultimaker specific junction velocity calculation. Args: state_A: (state) start state state_B: (state) end state """ super().__init__(state_A, state_B) - - self.calc_j_delta() self.calc_j_vel() - def calc_j_delta(self): - """Calculate the junction deviation with klipper specific values. - - The jerk value represents the square_corner_velocity! - """ - sc_vel = (self.state_B.state_p_settings.jerk) ** 2 - self.j_delta = sc_vel * (math.sqrt(2.0) - 1.0) / self.state_B.state_p_settings.p_acc - def calc_j_vel(self): """Calculate the junction velocity.""" vel_0 = self.target_vel vel_1 = self.vel_next - - if vel_0.get_norm() == 0 or vel_1.get_norm() == 0: - self.junction_vel = 0 - return - - # calculate junction angle - dir0 = vel_0.get_norm_dir() - dir1 = vel_1.get_norm_dir() - j_cos_theta = -( - dir0[0] * dir1[0] + dir0[1] * dir1[1] + dir0[2] * dir1[2] - ) # cos of theta, theta: small angle between velocity vectors - - j_cos_theta = max(j_cos_theta, -0.999999) # limit - if j_cos_theta > 0.999999: - self.junction_vel = 0 # self.target_vel.get_norm() # if self.target_vel.get_norm() is not None else 0 - return - j_sin_theta_d2 = math.sqrt(0.5 * (1.0 - j_cos_theta)) - - j_R = self.j_delta * j_sin_theta_d2 / (1.0 - j_sin_theta_d2) - - # [from klipper]: Approximated circle must contact moves no further away than mid-move - j_tan_theta_d2 = j_sin_theta_d2 / math.sqrt(0.5 * (1.0 + j_cos_theta)) - - move_centripetal_v2 = 0.5 * self.t_distance * j_tan_theta_d2 * self.state_B.state_p_settings.p_acc - - self.junction_vel = math.sqrt( - min(self.state_B.state_p_settings.p_acc * j_R, move_centripetal_v2, self.state_B.state_p_settings.speed**2) - ) + p_settings = self.state_B.state_p_settings + + # max jerk values + max_xy_jerk = p_settings.jerk + max_z_jerk = getattr(p_settings, "jerk_z", p_settings.jerk) + max_e_jerk = getattr(p_settings, "jerk_e", p_settings.jerk) + + # current and previous speeds + curr_speed = vel_1.get_vec(withExtrusion=True) + prev_speed = vel_0.get_vec(withExtrusion=True) + + # XY jerk + xy_jerk = np.sqrt((curr_speed[0] - prev_speed[0]) ** 2 + (curr_speed[1] - prev_speed[1]) ** 2) + z_jerk = abs(curr_speed[2] - prev_speed[2]) + e_jerk = abs(curr_speed[3] - prev_speed[3]) + + # Initial vmax_junction + vmax_junction = max_xy_jerk / 2.0 + vmax_junction_factor = 1.0 + + if abs(curr_speed[2]) > max_z_jerk / 2.0: + vmax_junction = min(vmax_junction, max_z_jerk / 2.0) + if abs(curr_speed[3]) > max_e_jerk / 2.0: + vmax_junction = min(vmax_junction, max_e_jerk / 2.0) + + vmax_junction = min(vmax_junction, vel_1.get_norm()) + # safe_speed = vmax_junction + + # If there is a previous move (simulate moves_queued > 1) + if vel_0.get_norm() > 0.0001: + vmax_junction = vel_1.get_norm() + if xy_jerk > max_xy_jerk: + vmax_junction_factor = max_xy_jerk / xy_jerk + if z_jerk > max_z_jerk: + vmax_junction_factor = min(vmax_junction_factor, max_z_jerk / z_jerk) + if e_jerk > max_e_jerk: + vmax_junction_factor = min(vmax_junction_factor, max_e_jerk / e_jerk) + vmax_junction = min(vel_0.get_norm(), vmax_junction * vmax_junction_factor) + + self.junction_vel = vmax_junction def get_junction_vel(self): """Return the calculated junction velocity. @@ -274,62 +359,234 @@ def get_junction_vel(self): return self.junction_vel -class junction_handling_MKA(junction_handling): - """Anisoprint A4 like junction handling. +class mka(prusa): + """Anisoprint Composer models using MKA Firmware junction handling. + + The MKA firmware uses a similar approach to Prusa's classic jerk handling. + + **Code reference:** + [anisoprint/MKA-firmware/src/core/planner/planner.cpp](https://github.com/anisoprint/MKA-firmware/blob/6e02973b1b8f325040cc3dbf66ac545ffc5c06b3/src/core/planner/planner.cpp#L1830) + ```cpp + // ... + float v_exit = previous_speed[axis] * smaller_speed_factor, + v_entry = current_speed[axis]; + if (limited) { + v_exit *= v_factor; + v_entry *= v_factor; + } + + // Calculate jerk depending on whether the axis is coasting in the same direction or reversing. + const float jerk = (v_exit > v_entry) + ? // coasting axis reversal + ( (v_entry > 0 || v_exit < 0) ? (v_exit - v_entry) : max(v_exit, -v_entry) ) + : // v_exit <= v_entry coasting axis reversal + ( (v_entry < 0 || v_exit > 0) ? (v_entry - v_exit) : max(-v_exit, v_entry) ); + + const float maxj = mechanics.max_jerk[axis]; + if (jerk > maxj) { + v_factor *= maxj / jerk; + ++limited; + } + } + if (limited) vmax_junction *= v_factor; + // ... + ``` + + """ + + # MKA is similar to Prusa jerk handling + + +class junction_deviation(junction_handling): + """Marlin specific junction handling with Junction Deviation. **Reference:** - [https://github.com/anisoprint/MKA-firmware/blob/6e02973b1b8f325040cc3dbf66ac545ffc5c06b3/src/core/planner/planner.cpp#L1830](https://github.com/anisoprint/MKA-firmware/blob/6e02973b1b8f325040cc3dbf66ac545ffc5c06b3/src/core/planner/planner.cpp#L1830) + 1: [Developer Blog](https://onehossshay.wordpress.com/2011/09/24/improving_grbl_cornering_algorithm/) + 2: [Kynetic CNC Blog](http://blog.kyneticcnc.com/2018/10/computing-junction-deviation-for-marlin.html) """ + def calc_JD(self, vel_0: velocity, vel_1: velocity, p_settings: state.p_settings): + """Calculate junction deviation velocity from 2 velocities. + + Args: + vel_0: (velocity) entry + vel_1: (velocity) exit + p_settings: (state.p_settings) print settings + + Returns: + velocity: (float) velocity abs value + """ + # Junction deviation settings + JD_acc = p_settings.p_acc + if p_settings.jerk == 0: + return 0 + JD_delta = 0.414 * p_settings.jerk * p_settings.jerk / JD_acc # [2] + JD_minAngle = 18 + JD_maxAngle = 180 - 18 + vel_0_vec = vel_0.get_vec() + vel_1_vec = vel_1.get_vec() + if vel_0.get_norm() == 0 or vel_1.get_norm() == 0: + return 0 + # calculate junction angle + JD_cos_theta = np.dot(-np.asarray(vel_0_vec), np.asarray(vel_1_vec)) / ( + np.linalg.norm(vel_0_vec) * np.linalg.norm(vel_1_vec) + ) # cos of theta, theta: small angle between velocity vectors + if JD_cos_theta < 1: # catch numerical errors where cos theta is slightly larger than one + JD_sin_theta_half = np.sqrt((1 - JD_cos_theta) / 2) + else: + JD_sin_theta_half = 0 + if JD_sin_theta_half < np.sin(JD_maxAngle * np.pi / (2 * 180)): # smaller than max angle + if JD_sin_theta_half > np.sin( + JD_minAngle * np.pi / (2 * 180) + ): # and larger than min angle --> apply Junction Deviation Calculation + # calculate scalar junction velocity + JD_Radius = JD_delta * JD_sin_theta_half / (1 - JD_sin_theta_half) + JD_velocity_scalar = np.sqrt(JD_acc * JD_Radius) + + # return JD_velocity_scalar if JD_velocity_scalar < vel_0.get_norm() else vel_0.get_norm() + return JD_velocity_scalar if JD_velocity_scalar < p_settings.speed else p_settings.speed + else: + return 0 # angle smaller than min angle, stop completely + else: + return p_settings.speed # angle larger than max angle, full speed pass + def __init__(self, state_A: state, state_B: state): - """Marlin classic jerk specific junction velocity calculation. + """Marlin specific junction velocity calculation with Junction Deviation. Args: state_A: (state) start state state_B: (state) end state """ super().__init__(state_A, state_B) + self.junction_vel = self.calc_JD( + vel_0=self.target_vel, vel_1=self.vel_next, p_settings=self.state_B.state_p_settings + ) - self.calc_j_vel() + def get_junction_vel(self): + """Return junction velocity. - def calc_j_vel(self): - """Calculate the junction velocity.""" - vel_0 = self.target_vel - vel_1 = self.vel_next - self.jerk = self.state_B.state_p_settings.jerk + Returns: + junction_vel: (float) junction velocity + """ + return self.junction_vel - v_max_junc = min(vel_0.get_norm(), vel_1.get_norm()) - small_speed_fac = v_max_junc / vel_0.get_norm() if v_max_junc > 0 else 0 - v_factor = 1 - lim_flag = False +# class junction_handling_klipper(junction_handling): - for v_entry, v_exit in zip(vel_0.get_vec(withExtrusion=True), vel_1.get_vec(withExtrusion=True)): - v_exit *= small_speed_fac +# """Klipper specific junction handling. - if lim_flag: - v_entry *= v_factor - v_exit *= v_factor +# - similar junction deviation calc +# - corner vel set by: square_corner_velocity +# end_velocity^2 = start_velocity^2 + 2*accel*move_distance +# for 90deg turn +# - todo: smoothed look ahead - jerk = ( - ((v_exit - v_entry) if (v_entry > 0 or v_exit < 0) else max(v_exit, -v_entry)) - if (v_exit > v_entry) - else (v_entry - v_exit if (v_entry < 0 or v_exit > 0) else max(-v_exit, v_entry)) - ) # calc logic taken from MKA firmware +# **Reference:** +# [https://www.klipper3d.org/Kinematics.html](https://www.klipper3d.org/Kinematics.html) +# [https://github.com/Klipper3d/klipper/blob/ea2f6bc0f544132738c7f052ffcc586fa884a19a/klippy/toolhead.py](https://github.com/Klipper3d/klipper/blob/ea2f6bc0f544132738c7f052ffcc586fa884a19a/klippy/toolhead.py) +# """ +# import math - if jerk > self.jerk: - v_factor *= self.jerk / jerk - lim_flag = True +# def __init__(self, state_A: state, state_B: state): +# """Klipper specific junction velocity calculation. - if lim_flag: - v_max_junc *= v_factor +# Args: +# state_A: (state) start state +# state_B: (state) end state +# """ +# super().__init__(state_A, state_B) - self.junction_vel = v_max_junc +# self.calc_j_delta() +# self.calc_j_vel() - def get_junction_vel(self): - """Return the calculated junction velocity. +# def calc_j_delta(self): +# """Calculate the junction deviation with klipper specific values. - Returns: - junction_vel: (float) junction velocity - """ - return self.junction_vel +# The jerk value represents the square_corner_velocity! +# """ +# sc_vel = (self.state_B.state_p_settings.jerk) ** 2 +# self.j_delta = sc_vel * (math.sqrt(2.0) - 1.0) / self.state_B.state_p_settings.p_acc + +# def calc_j_vel(self): +# """Calculate the junction velocity.""" +# vel_0 = self.target_vel +# vel_1 = self.vel_next + +# if vel_0.get_norm() == 0 or vel_1.get_norm() == 0: +# self.junction_vel = 0 +# return + +# # calculate junction angle +# dir0 = vel_0.get_norm_dir() +# dir1 = vel_1.get_norm_dir() +# j_cos_theta = -( +# dir0[0] * dir1[0] + dir0[1] * dir1[1] + dir0[2] * dir1[2] +# ) # cos of theta, theta: small angle between velocity vectors + +# j_cos_theta = max(j_cos_theta, -0.999999) # limit +# if j_cos_theta > 0.999999: +# self.junction_vel = 0 # self.target_vel.get_norm() # if self.target_vel.get_norm() is not None else 0 +# return +# j_sin_theta_d2 = math.sqrt(0.5 * (1.0 - j_cos_theta)) + +# j_R = self.j_delta * j_sin_theta_d2 / (1.0 - j_sin_theta_d2) + +# # [from klipper]: Approximated circle must contact moves no further away than mid-move +# j_tan_theta_d2 = j_sin_theta_d2 / math.sqrt(0.5 * (1.0 + j_cos_theta)) + +# move_centripetal_v2 = 0.5 * self.t_distance * j_tan_theta_d2 * self.state_B.state_p_settings.p_acc + +# self.junction_vel = math.sqrt( +# min(self.state_B.state_p_settings.p_acc * j_R, move_centripetal_v2, self.state_B.state_p_settings.speed**2) +# ) + +# def get_junction_vel(self): +# """Return the calculated junction velocity. + +# Returns: +# junction_vel: (float) junction velocity +# """ +# return self.junction_vel + + +def get_handler(firmware_name: str) -> type[junction_handling]: + """Get the junction handling class for the given firmware name. + + Args: + firmware_name: (str) name of the firmware + + Returns: + junction_handling: (type[junction_handling]) junction handling class + """ + if firmware_name == "prusa": + return prusa + elif firmware_name == "junction_deviation": + return junction_deviation + elif firmware_name == "marlin": + return marlin + elif firmware_name == "ultimaker": + return ultimaker + elif firmware_name == "mka": + return mka + else: + custom_print( + f"Using NO (zero interfacing velocity) junction handling handling for provided '{firmware_name}' firmware name.", + f"Use one of the following: {', '.join(_get_handler_names())} for proper junction handling.", + lvl=1, + ) + return junction_handling + + +def _get_handler_names() -> list[str]: + """Get the names of all available junction handling classes. + + Returns: + list[str]: List of junction handling class names. + """ + # Get all classes defined in this module that are subclasses of junction_handling (excluding the base itself) + current_module = sys.modules[__name__] + return [ + name + for name, obj in inspect.getmembers(current_module, inspect.isclass) + if issubclass(obj, junction_handling) and obj is not junction_handling + ] diff --git a/pyGCodeDecode/planner_block.py b/pyGCodeDecode/planner_block.py index 9c43909..e0333f1 100644 --- a/pyGCodeDecode/planner_block.py +++ b/pyGCodeDecode/planner_block.py @@ -5,14 +5,9 @@ import numpy as np from pyGCodeDecode.helpers import custom_print +from pyGCodeDecode.result import abstract_result, acceleration_result, velocity_result -from .junction_handling import ( - junction_handling, - junction_handling_klipper, - junction_handling_marlin_jd, - junction_handling_marlin_jerk, - junction_handling_MKA, -) +from .junction_handling import get_handler from .state import state from .utils import segment, velocity @@ -20,12 +15,17 @@ class planner_block: """Planner Block Class.""" - def move_maker2(self, v_end): + result_calculators: List[abstract_result] = [ + acceleration_result(), + velocity_result(), + ] + + def move_maker(self, v_end): """ Calculate the correct move type (trapezoidal,triangular or singular) and generate the corresponding segments. Args: - vel_end: (velocity) target velocity for end of move + v_end: (velocity) target velocity for end of move """ def trapezoid(extrusion_only=False): @@ -198,7 +198,7 @@ def singl_dwn(): ) except ValueError as ve: - custom_print(f"Segments to state: {str(self.state_B)} could not be modeled.\n {ve}") + custom_print(f"Segments to state: {str(self.state_B)} could not be modeled.\n {ve}", lvl=1) raise RuntimeError() def self_correction(self, tolerance=float("1e-12")): @@ -218,7 +218,7 @@ def self_correction(self, tolerance=float("1e-12")): # Correct error by recalculating velocitys with new vel_end if self.next_block is not None and flag_correct: vel_end = self.next_block.get_segments()[0].vel_begin.get_norm() - self.move_maker2(v_end=vel_end) + self.move_maker(v_end=vel_end) if self.blocktype == "single": self.prev_block.self_correction() # forward correction? @@ -241,7 +241,9 @@ def self_correction(self, tolerance=float("1e-12")): for segm in self.segments: segm.self_check(p_settings=self.state_B.state_p_settings) except ValueError as ve: - custom_print(f"Segment for {self.state_B} does not adhere to machine limits: {ve}") + custom_print( + f"⚠️ Segment modeling travel to \n\t{self.state_B}\ndoes not adhere to machine limits: {ve}", lvl=1 + ) return flag_correct @@ -278,6 +280,16 @@ def extrusion_block_max_vel(self) -> Union[np.ndarray, None]: else: return None + def calc_results(self, *additional_calculators: abstract_result): + """Calculate the result of the planner block.""" + for calculator in self.result_calculators: + calculator.calc_pblock(self) + + if additional_calculators: + for calculator in additional_calculators: + if calculator not in self.result_calculators: + calculator.calc_pblock(self) + def __init__(self, state: state, prev_block: "planner_block", firmware=None): """Calculate and store planner block consisting of one or multiple segments. @@ -295,31 +307,24 @@ def __init__(self, state: state, prev_block: "planner_block", firmware=None): self.segments: List[segment] = [] # store segments here self.blocktype = None + self.e_type = None # use for extrusion type e.g. perimeter, infill ... - if firmware == "marlin_jd": - junction = junction_handling_marlin_jd(state_A=self.state_A, state_B=self.state_B) - elif firmware == "klipper": - junction = junction_handling_klipper(state_A=self.state_A, state_B=self.state_B) - elif firmware == "marlin_jerk": - junction = junction_handling_marlin_jerk(state_A=self.state_A, state_B=self.state_B) - elif firmware == "MKA": - junction = junction_handling_MKA(state_A=self.state_A, state_B=self.state_B) - else: - junction = junction_handling(state_A=self.state_A, state_B=self.state_B) + handler = get_handler(firmware_name=firmware) # get junction handler + junction = handler(state_A=self.state_A, state_B=self.state_B) # planner block calculation - target_vel = junction.get_target_vel() # target velocity for this planner block + self.target_vel = junction.get_target_vel() # target velocity for this planner block v_JD = junction.get_junction_vel() - self.direction = target_vel.get_norm_dir(withExtrusion=True) # direction vector of pb + self.direction = self.target_vel.get_norm_dir(withExtrusion=True) # direction vector of pb - self.valid = target_vel.not_zero() # valid planner block + self.valid = self.target_vel.not_zero() # valid planner block # standard move maker if self.valid: self.JD = v_JD * self.direction # jd writeout for debugging plot - self.move_maker2(v_end=v_JD) + self.move_maker(v_end=v_JD) self.is_extruding = self.state_A.state_position.is_extruding( self.state_B.state_position ) # store extrusion flag @@ -357,15 +362,56 @@ def next_block(self, block: "planner_block"): self._next_block = block def __str__(self) -> str: - """Create string from planner block.""" + """Create a visually aligned ASCII art string for planner block.""" + # Get positions as strings + pos_A = str(self.state_A.state_position) + pos_B = str(self.state_B.state_position) + + # Get velocities + v_begin = self.segments[0].vel_begin.get_norm() if self.segments else 0 + v_end = self.segments[-1].vel_end.get_norm() if self.segments else 0 + v_max = max(segm.vel_begin.get_norm() for segm in self.segments) if self.segments else 0 + + # Pad positions for alignment + padsize = 40 + pos_A_pad = f"{pos_A:<{padsize}}" + pos_B_pad = f"{pos_B:>{padsize}}" + + # Velocity profile + profile_width = 24 if len(self.segments) == 3: - return "{:-^40}".format("Trapezoid Planner Block") + # Trapezoid: ramp up, constant, ramp down + profile = f"/{'β€Ύ'*(profile_width-2)}\\" + block_type = "Trapezoid" elif len(self.segments) == 2: - return "{:-^40}".format("Triangular Planner Block") + # Triangle: ramp up, ramp down + profile = "/\\".center(profile_width) + block_type = "Triangle" elif len(self.segments) == 1: - return "{:-^40}".format("Singular Planner Block") + # Single: ramp up or down only + # Center the single segment profile + if v_begin < v_end: + profile = "/".center(profile_width) + else: + profile = "\\".center(profile_width) + block_type = "Single" else: - return "{:#^40}".format("Invalid Planner Block") + profile = "{invalid block}" + block_type = "Invalid" + v_begin = f"{v_begin:.2f} mm/s" + v_beg_str = f"{v_begin:>8}" + v_end = f"{v_end:.2f} mm/s" + v_end_str = f"{v_end:<8}" + tot_len = len(pos_A_pad) + len(profile) + len(pos_B_pad) + + lines = [ + f"{block_type} Planner Block".center(tot_len, "-"), + "", + f"{v_max:.2f} mm/s".center(tot_len), + f"{' '*(len(pos_A_pad)-len(v_beg_str))}{v_beg_str}{profile}{v_end_str}", + f"{pos_A_pad}{' '*(profile_width)}{pos_B_pad}", + ] + return "\n".join(lines) + "\n" def __repr__(self) -> str: """Represent planner block.""" diff --git a/pyGCodeDecode/plotter.py b/pyGCodeDecode/plotter.py new file mode 100644 index 0000000..d1829ee --- /dev/null +++ b/pyGCodeDecode/plotter.py @@ -0,0 +1,518 @@ +"""This module provides functionality for 3D plotting of G-code simulation data using PyVista.""" + +import os +import pathlib +from typing import Tuple, Union + +import numpy as np +import pyvista as pv +from matplotlib.figure import Figure + +from pyGCodeDecode.gcode_interpreter import ( + find_current_segment, + simulation, + unpack_blocklist, +) +from pyGCodeDecode.helpers import ProgressBar, custom_print + + +def plot_3d( + sim: simulation, + extrusion_only: bool = True, + scalar_value: str = "velocity", + screenshot_path: pathlib.Path = None, + camera_settings: dict = None, + vtk_path: pathlib.Path = None, + mesh: pv.MultiBlock = None, + layer_select: int = None, + z_scaler: float = None, + window_size: tuple = (2048, 1536), + mpl_subplot: bool = False, + mpl_rcParams: Union[dict, None] = None, + solid_color: str = "black", + transparent_background: bool = True, + parallel_projection: bool = False, + lighting: bool = True, + block_colorbar: bool = False, + extra_plotting: callable = None, # function to add plotting, args: plotter, mesh + overwrite_labels: Union[dict, None] = None, + scalar_value_bounds: Union[Tuple[float, float], None] = None, + return_type: str = "mesh", # "mesh" or "image", only available with screenshot_path +) -> pv.MultiBlock: + """Plot a 3D visualization of G-code simulation data using PyVista. + + Args: + sim (simulation): The simulation object containing blocklist and segment data. + extrusion_only (bool, optional): If True, plot only segments where extrusion occurs. Defaults to True. + scalar_value (str, optional): Scalar value to color the plot. Options: "velocity", "rel_vel_err", "acceleration", or None. Defaults to "velocity". + screenshot_path (pathlib.Path, optional): If provided, saves a screenshot to this path and disables interactive plotting. Defaults to None. + camera_settings (dict, optional): Camera settings for the plotter. Keys: "camera_position", "elevation", "azimuth", "roll". Defaults to None. + vtk_path (pathlib.Path, optional): If provided, saves the mesh as a VTK file to this path. Defaults to None. + mesh (pv.MultiBlock, optional): Precomputed PyVista mesh to use instead of generating a new one. Defaults to None. + layer_select (int, optional): If provided, only plot the specified layer. Defaults to None (all layers). + z_scaler (float, optional): Scaling factor for the z-axis layer squishing (z_scaler = width/height of extrusion). Defaults to None (automatic scaling). + window_size (tuple, optional): Size of the plot window in pixels. Defaults to (2048, 1536). + mpl_subplot (bool, optional): If True, use matplotlib for screenshot and colorbar. Defaults to False. + mpl_rcParams (dict or None, optional): Custom matplotlib rcParams for styling. Defaults to None. + solid_color (str, optional): Background color for the plot. Defaults to "black". + transparent_background (bool, optional): If True, screenshot background is transparent. Defaults to True. + parallel_projection (bool, optional): If True, enables parallel projection in PyVista. Defaults to False. + lighting (bool, optional): If True, enables lighting in the plot. Defaults to True. + block_colorbar (bool, optional): If True, removes the scalar colorbar from the plot. Defaults to False. + extra_plotting (callable, optional): Function to add extra plotting to the PyVista plotter. Signature: (plotter, mesh). Defaults to None. + overwrite_labels (dict or None, optional): Dictionary to overwrite colorbar labels. Defaults to None. + scalar_value_bounds (tuple or None, optional): Tuple (min, max) to set scalar colorbar range. Defaults to None. + return_type (str, optional): Return type, "mesh" or "image". Defaults to "mesh". + + Returns: + pv.MultiBlock: The PyVista mesh used for plotting. + or + np.ndarray: The screenshot image if `screenshot_path` is provided and `return_type` is "image". + """ + + def _safe_screenshot(plotter: pv.Plotter, screenshot_path=None): + if display_available: + img = plotter.screenshot( + transparent_background=transparent_background, + filename=screenshot_path, + ) + if screenshot_path is not None: + custom_print(f"πŸ’Ύ PyVista Screenshot saved to πŸ‘‰ {screenshot_path}") + else: + img = None + custom_print("PyVista Screenshot can not be created without a display!", lvl=1) + return img + + colorbar_label = { + "velocity": [r"$v$ in $\frac{mm}{s}$", "v in mm/s", "viridis"], + "rel_vel_err": [r"$\epsilon_{\mathrm{loc}}$", "vel. Error", "Reds"], + "acceleration": [r"$a$ in $\frac{mm}{s^2}$", "a in mm/s^2", "viridis"], + } + if overwrite_labels: + colorbar_label.update(overwrite_labels) + + if layer_select is not None: + layer_blcklst = [block for block in sim.blocklist if block.state_B.layer == layer_select] + segments = unpack_blocklist(blocklist=layer_blcklst) + custom_print("Number of Segments in this Layer: ", len(segments), lvl=3) + else: + segments = unpack_blocklist(blocklist=sim.blocklist) + + if z_scaler is None: + e_width = 0.45 + l_height = 0.2 + z_scaler = e_width / l_height + + if mesh is None: + mesh = pv.MultiBlock() + x, y, z, e, scalar = [], [], [], [], [] + + bar = ProgressBar(name="3D Plot") + for n, segm in enumerate(segments): + bar.update((n + 1) / len(segments)) + + if (not extrusion_only) or (segm.is_extruding()): + if len(x) == 0: + pos_begin_vec = segm.pos_begin.get_vec(withExtrusion=True) + x.append(pos_begin_vec[0]) + y.append(pos_begin_vec[1]) + z.append(pos_begin_vec[2] * z_scaler) + e.append(pos_begin_vec[3]) + + if scalar_value is not None: + sc = segm.get_result(scalar_value) + if hasattr(sc, "__len__"): + scalar.append(sc[0]) + else: + scalar.append(sc) + + pos_end_vec = segm.pos_end.get_vec(withExtrusion=True) + x.append(pos_end_vec[0]) + y.append(pos_end_vec[1]) + z.append(pos_end_vec[2] * z_scaler) + e.append(pos_end_vec[3]) + + if scalar_value is not None: + sc = segm.get_result(scalar_value) + if hasattr(sc, "__len__"): + scalar.append(sc[1]) + else: + scalar.append(sc) + + if (extrusion_only and (len(x) > 0 and not segm.is_extruding())) or ( + len(x) > 0 and n == (len(segments) - 1) + ): + points_3d = np.column_stack((x, y, z)) + line = pv.lines_from_points(points_3d) + if scalar_value is not None: + line[scalar_value] = scalar + tube = line.tube(radius=e_width / 2, n_sides=6) + mesh.append(tube) + x, y, z, e, scalar = [], [], [], [], [] + + mesh = mesh.combine() + + # check wether a display is available or Windows is used + if os.name == "nt" or "DISPLAY" in os.environ: + display_available = True + else: + display_available = False + + # saving a screenshot and an interactive plot aren't possible at the same tim + if screenshot_path is None: + off_screen = False + else: + off_screen = True + + if off_screen: + p = pv.Plotter(off_screen=off_screen, window_size=window_size) + else: + p = pv.Plotter(off_screen=off_screen) + + if parallel_projection: + p.enable_parallel_projection() + + p.set_scale(zscale=1 / z_scaler) + + if extra_plotting: + try: + custom_print("Running extra plotting function", lvl=3) + extra_plotting(p, mesh) + except Exception as e: + custom_print(f"Error in extra plotting function: {e}", lvl=1) + + if scalar_value is not None: + actor = p.add_mesh( + mesh, + scalars=scalar_value, + smooth_shading=True, + scalar_bar_args={ + "title": colorbar_label[scalar_value][1], + "title_font_size": 40, + "label_font_size": 25, + "width": 0.05, + "vertical": True, + "font_family": "arial", + }, + cmap=colorbar_label[scalar_value][-1], + lighting=lighting, + ) + if scalar_value == "rel_vel_err": + p.update_scalar_bar_range([0, 1]) + + if scalar_value_bounds is not None: + p.update_scalar_bar_range(scalar_value_bounds) + else: + p.set_background(solid_color) + p.add_mesh(mesh, color=solid_color, smooth_shading=True, lighting=lighting) + + if layer_select is not None: + p.view_xy() + + if camera_settings is not None: + p.camera_position = camera_settings["camera_position"] + if camera_settings.get("elevation", False): + p.camera.elevation = camera_settings["elevation"] + if camera_settings.get("azimuth", False): + p.camera.azimuth = camera_settings["azimuth"] + if camera_settings.get("roll", False): + p.camera.roll = camera_settings["roll"] + + if vtk_path is not None: + mesh.save(filename=vtk_path) + custom_print(f"πŸ’Ύ VTK saved to πŸ‘‰ {vtk_path}", lvl=2) + + if screenshot_path is not None: + custom_print(f"Offscreen plotting, with resolution {p.window_size}", lvl=3) + + if mpl_subplot: + import matplotlib.pyplot as plt + + if isinstance(mpl_rcParams, dict): + custom_print( + f"Using custom matplotlib rcParams in plot_3d.\n{mpl_rcParams}", + lvl=3, + ) + # print("rcParams:", mpl_rcParams) + plt.rcParams.update(mpl_rcParams) + # print("Updated rcParams:", plt.rcParams) + + fig, ax = plt.subplots() + + if scalar_value is not None: + lut = actor.mapper.lookup_table + if scalar_value_bounds is None: + min_val = lut.GetRange()[0] + max_val = lut.GetRange()[1] + else: + min_val, max_val = scalar_value_bounds + + dummy_img = ax.imshow( + np.array([[min_val, max_val]]), cmap=colorbar_label[scalar_value][-1], vmin=min_val, vmax=max_val + ) + + p.remove_scalar_bar() + + # image = p.screenshot(transparent_background=True, window_size=window_size) + image = _safe_screenshot(p) + # ax.axis("off") + if not block_colorbar: + cbar = fig.colorbar(dummy_img, ax=ax, shrink=0.6) + cbar.set_label(colorbar_label[scalar_value][0], fontsize=20) + + else: + # image = p.screenshot(transparent_background=True) + image = _safe_screenshot(p) + ax.axis("off") + if image is not None: + ax.imshow(image) + fig.tight_layout() + dpi = window_size[1] / fig.get_size_inches()[1] + fig.savefig(screenshot_path, dpi=dpi, transparent=transparent_background) # bbox_inches="tight", + + custom_print(f"πŸ’Ύ MPL Screenshot saved to πŸ‘‰{screenshot_path}") + else: + if block_colorbar: + p.remove_scalar_bar() + image = _safe_screenshot(p, screenshot_path) + + if return_type == "image": + return image + return mesh + + if not off_screen and display_available: + p.show() + + return mesh + + +def plot_2d( + sim: simulation, + filepath: pathlib.Path = pathlib.Path("trajectory_2D.png"), + colvar="Velocity", + show_points=False, + colvar_spatial_resolution=1, + dpi=400, + scaled=True, + show=False, +): + """Plot 2D position (XY plane) with matplotlib (unmaintained).""" + import matplotlib.pyplot as plt + from matplotlib import cm + from matplotlib.collections import LineCollection + + colvar_label = { + "Velocity": "Velocity in mm/s", + "Acceleration": "Acceleration in mm/s^2", + } + + def _interp_2D(x, y, cvar, spatial_resolution=1): + segm_length = np.linalg.norm([np.ediff1d(x), np.ediff1d(y)], axis=0) + segm_cvar_delt = np.greater(np.abs(np.ediff1d(cvar)), 0) + segm_interpol = np.r_[ + 0, + np.where(segm_cvar_delt, np.ceil(segm_length / spatial_resolution) + 1, 1), + ] # get nmbr of segments for required resolution, dont interpolate if there is no change + points = np.array([x, y, cvar]).T + points = np.c_[points, segm_interpol] + + # generate intermediate points with set resolution + old_point = None + interpolated = np.zeros((1, 3)) + for point in points: + if old_point is not None: + steps = np.linspace(0, 1, int(point[3]), endpoint=True) + x_i = np.interp(steps, [0, 1], [old_point[0], point[0]]) + y_i = np.interp(steps, [0, 1], [old_point[1], point[1]]) + colvar_i = np.interp(steps, [0, 1], [old_point[2], point[2]]) + interpolated = np.r_[interpolated, np.array([x_i, y_i, colvar_i]).T] + old_point = point + interpolated = np.delete(interpolated, 0, 0) + + return interpolated + + segments = unpack_blocklist(blocklist=sim.blocklist) + if colvar == "Velocity": + # get all planned trajectory vertices + color variable + x, y, cvar = [], [], [] + x.append(segments[0].pos_begin.get_vec()[0]) + y.append(segments[0].pos_begin.get_vec()[1]) + cvar.append(segments[0].vel_begin.get_norm()) + + bar = ProgressBar(name="2D Plot Lines") + for i, segm in enumerate(segments): + bar.update((i + 1) / len(segments)) + x.append(segm.pos_end.get_vec()[0]) + y.append(segm.pos_end.get_vec()[1]) + cvar.append(segm.vel_end.get_norm()) + + # interpolate values for smooth coloring + interpolated = _interp_2D(x, y, cvar, spatial_resolution=colvar_spatial_resolution) + + x = interpolated[:, 0] + y = interpolated[:, 1] + cvar = interpolated[:, 2] # maybe change interpolation to return tuple? + + # generate point pairs for line collection + point_pairs = [] + for i in np.arange(len(x) - 1): + point_pairs.append([(x[i], y[i]), (x[i + 1], y[i + 1])]) + + # generate collection from pairs + collection = LineCollection(point_pairs) + collection.set_array(cvar) + collection.set_cmap(cm.jet) + + fig = plt.figure() + ax1 = fig.add_subplot(1, 1, 1) + ax1.add_collection(collection) + ax1.autoscale() + plt.colorbar(collection, label=colvar_label[colvar], shrink=0.6, location="right") + else: + x, y = [], [] + x.append(segments[0].pos_begin.get_vec()[0]) + y.append(segments[0].pos_begin.get_vec()[1]) + for i, segm in enumerate(segments): + bar.update((i + 1) / len(segments)) + x.append(segm.pos_end.get_vec()[0]) + y.append(segm.pos_end.get_vec()[1]) + fig = plt.subplot() + fig.plot(x, y, color="black") + + if show_points: + for i, block in enumerate(sim.blocklist): + bar.update(i / len(sim.blocklist)) + fig.scatter( + block.get_segments()[-1].pos_end.get_vec()[0], + block.get_segments()[-1].pos_end.get_vec()[1], + color="blue", + marker="x", + ) + + plt.xlabel("x position") + plt.ylabel("y position") + plt.title("2D Position") + if scaled: + plt.axis("scaled") + if filepath is not False: + plt.savefig(filepath, dpi=dpi) + custom_print(f"πŸ’Ύ 2D Plot saved πŸ‘‰ {filepath}") + if show: + plt.show() + return fig + plt.close() + + +def plot_vel( + sim: simulation, + axis: Tuple[str] = ("x", "y", "z", "e"), + show: bool = True, + show_planner_blocks: bool = True, + show_segments: bool = False, + show_jv: bool = False, + time_steps: Union[int, str] = "constrained", + filepath: pathlib.Path = None, + dpi: int = 400, +) -> Figure: + """Plot axis velocity with matplotlib. + + Args: + axis: (tuple(string), default = ("x", "y", "z", "e")) select plot axis + show: (bool, default = True) show plot and return plot figure + show_planner_blocks: (bool, default = True) show planner_blocks as vertical lines + show_segments: (bool, default = False) show segments as vertical lines + show_jv: (bool, default = False) show junction velocity as x + time_steps: (int or string, default = "constrained") number of time steps or constrain plot + vertices to segment vertices + filepath: (Path, default = None) save fig as image if filepath is provided + dpi: (int, default = 400) select dpi + + Returns: + (optionally) + fig: (figure) + """ + import matplotlib.pyplot as plt + + axis_dict = {"x": 0, "y": 1, "z": 2, "e": 3} + + segments = unpack_blocklist(blocklist=sim.blocklist) # unpack + + # time steps + if type(time_steps) is int: # evenly distributed time steps + times = np.linspace( + 0, + sim.blocklist[-1].get_segments()[-1].t_end, + time_steps, + endpoint=False, + ) + elif time_steps == "constrained": # use segment time points as plot constrains + times = [0] + for segm in segments: + times.append(segm.t_end) + else: + raise ValueError("Invalid value for 'time_steps', either use Integer or 'constrained' as argument.") + + # gathering values + pos = [[], [], [], []] + vel = [[], [], [], []] + abs = [] # initialize value arrays + index_saved = 0 + bar = ProgressBar(name="Velocity Plot") + + for i, t in enumerate(times): + segm, index_saved = find_current_segment(path=segments, t=t, last_index=index_saved, keep_position=True) + + tmp_vel = segm.get_velocity(t=t).get_vec(withExtrusion=True) + tmp_pos = segm.get_position(t=t).get_vec(withExtrusion=True) + for ax in axis: + pos[axis_dict[ax]].append(tmp_pos[axis_dict[ax]]) + vel[axis_dict[ax]].append(tmp_vel[axis_dict[ax]]) + + abs.append(np.linalg.norm(tmp_vel[:3])) + bar.update((i + 1) / len(times)) + + fig, ax1 = plt.subplots() + ax2 = ax1.twinx() + + # plot JD-Limits + for block in sim.blocklist: + # planner blocks vertical line plot + if show_planner_blocks: + ax1.axvline(x=block.get_segments()[-1].t_end, color="black", lw=0.5) + + # segments vertical line plot + if show_segments: + for segm in block.get_segments(): + ax1.axvline(x=segm.t_end, color="green", lw=0.25) + + if show_jv: + # absolute JD Marker + absJD = np.linalg.norm([block.JD[0], block.JD[1], block.JD[2]]) + ax1.scatter(x=block.get_segments()[-1].t_end, y=absJD, color="red", marker="x") + for ax in axis: + ax1.scatter( + x=block.get_segments()[-1].t_end, + y=block.JD[axis_dict[ax]], + marker="x", + color="black", + lw=0.5, + ) + + # plot all axis in velocity and position + for ax in axis: + ax1.plot(times, vel[axis_dict[ax]], label=ax) # velocity + ax2.plot(times, pos[axis_dict[ax]], linestyle="--") # position w/ extrusion + # if not ax == "e": ax2.plot(times,pos[axis_dict[ax]],linestyle="--") #position ignoring extrusion + ax1.plot(times, abs, color="black", label="abs") # absolute velocity + + ax1.set_xlabel("time in s") + ax1.set_ylabel("velocity in mm/s") + ax2.set_ylabel("position in mm") + ax1.legend(loc="lower left") + plt.title("Velocity and Position over Time") + if filepath is not None: + plt.savefig(filepath, dpi=dpi) + if show: + plt.show() + plt.close() + return fig diff --git a/pyGCodeDecode/result.py b/pyGCodeDecode/result.py new file mode 100644 index 0000000..ec0e5cd --- /dev/null +++ b/pyGCodeDecode/result.py @@ -0,0 +1,121 @@ +"""Result calculation for segments and planner blocks.""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pyGCodeDecode.planner_block import planner_block + +from pyGCodeDecode.utils import segment + + +# new segment class spanned by pos, rest is "result" +class abstract_result(ABC): + """Abstract class for result calculation.""" + + @property + @abstractmethod + def name(self): + """Name of the result. Has to be set in the derived class.""" + pass + + @abstractmethod + def calc_pblock(self, pblock: "planner_block", **kwargs): + """Calculate the result for a planner block.""" + pass + + @abstractmethod + def calc_segm(self, segm: "segment", **kwargs): + """Calculate the result for a segment.""" + pass + + +class acceleration_result(abstract_result): + """The acceleration.""" + + name = "acceleration" + + def calc_segm(self, segm: "segment", **kwargs): + """Calculate the acceleration for a segment.""" + delta_v = segm.vel_end.get_norm() - segm.vel_begin.get_norm() + delta_t = segm.get_segm_duration() + + if delta_t > 0: + acc = delta_v / delta_t + else: + acc = 0 + segm.result[self.name] = acc + + def calc_pblock(self, pblock, **kwargs): + """Calculate the acceleration for a planner block.""" + for segm in pblock.segments: + self.calc_segm(segm, **kwargs) + + +class velocity_result(abstract_result): + """The velocity.""" + + name = "velocity" + + def calc_segm(self, segm: "segment", **kwargs): + """Calculate the velocity for a segment.""" + segm.result[self.name] = [ + segm.vel_begin.get_norm(), + segm.vel_end.get_norm(), + ] + + def calc_pblock(self, pblock: "planner_block", **kwargs): + """Calculate the velocity for a planner block.""" + for segm in pblock.segments: + self.calc_segm(segm, **kwargs) + + +def get_all_result_calculators(): + """Get all results.""" + public_results = [ + acceleration_result(), + velocity_result(), + ] + + private_results = [] + # Try to import private results if the module exists + try: + from pyGCodeDecode.private_result import get_private_result_calculators + + private_results = get_private_result_calculators() + except ImportError: + # Private results module not available, continue with only public results + pass + + return public_results + private_results + + +def has_private_results(): + """Check if private results are available.""" + try: + from pyGCodeDecode.private_result import ( # noqa: F401 + get_private_result_calculators, + ) + + return True + except ImportError: + return False + + +def get_result_info(): + """Get information about available result calculators.""" + all_calcs = get_all_result_calculators() + public_count = 2 # acceleration_result and velocity_result + private_count = len(all_calcs) - public_count + + return { + "total_count": len(all_calcs), + "public_count": public_count, + "private_count": private_count, + "has_private": has_private_results(), + "result_names": [calc.name for calc in all_calcs], + } + + +if __name__ == "__main__": + pass diff --git a/pyGCodeDecode/state.py b/pyGCodeDecode/state.py index a181e0a..1991a17 100644 --- a/pyGCodeDecode/state.py +++ b/pyGCodeDecode/state.py @@ -34,23 +34,13 @@ def __init__(self, p_acc, jerk, vX, vY, vZ, vE, speed, units="SI (mm)"): def __str__(self) -> str: """Create summary string for p_settings.""" return ( - "jerk: " - + str(self.jerk) - + ", p_acc: " - + str(self.p_acc) - + ", max_ax_vel: [" - + str(self.vX) - + ", " - + str(self.vY) - + ", " - + str(self.vZ) - + ", " - + str(self.vE) - + "]" - + ", p_vel: " - + str(self.speed) - + ", units: " - + str(self.units) + f"Settings:\n" + f" jerk: {self.jerk}\n" + f" acceleration: {self.p_acc}\n" + f" max_velocities: [X: {self.vX}, Y: {self.vY}, Z: {self.vZ}, E: {self.vE}]\n" + f" speed: {self.speed}\n" + f" units: {self.units}" + f"" ) def __repr__(self) -> str: @@ -93,7 +83,7 @@ def state_p_settings(self, set_p_settings: p_settings): @property def line_number(self): - """Define property line_nmbr.""" + """Define property line_number.""" return self._line_nmbr @line_number.setter @@ -136,10 +126,40 @@ def prev_state(self, state: "state"): def __str__(self) -> str: """Generate string for representation.""" + # Format the basic state info + state_info = f"State(line: {self.line_number or 'N/A'})" + + # Add layer if available if self.layer is not None: - return f"\n" # noqa E501 + state_info += f" [Layer: {self.layer}]" + + # Add pause status if set + if self.pause: + state_info += " [PAUSED]" + + # Build the complete representation + result = f"{state_info}\n" + + # Add position information + if self.state_position: + result += f"\t{self.state_position}\n" else: - return f"\n" # noqa E501 + result += "Position: Not set\n" + + # Add settings information with indentation + if self.state_p_settings: + settings_str = str(self.state_p_settings) + # Indent each line of settings + indented_settings = "\n".join(f"\t{line}" for line in settings_str.split("\n")) + result += f" {indented_settings}" + else: + result += "Settings: Not set" + + # Add comment if available + if self.comment: + result += f"Comment: {self.comment}" + + return result def __repr__(self) -> str: """Call __str__() for representation.""" diff --git a/pyGCodeDecode/state_generator.py b/pyGCodeDecode/state_generator.py index 7acc298..3292350 100644 --- a/pyGCodeDecode/state_generator.py +++ b/pyGCodeDecode/state_generator.py @@ -1,10 +1,11 @@ """State generator module.""" +import math import pathlib import re from typing import List, Match -from pyGCodeDecode.helpers import custom_print +from pyGCodeDecode.helpers import ProgressBar, custom_print from .state import state from .utils import position @@ -81,6 +82,7 @@ default_virtual_machine = { "absolute_position": True, "absolute_extrusion": True, + "volumetric_extrusion": False, "units": "SI (mm)", "initial_position": None, # general properties @@ -88,7 +90,6 @@ "filament_diam": 1.75, # default settings "p_vel": 35, - "t_vel": 35, "p_acc": 200, "jerk": 10, # axis max speeds @@ -104,7 +105,7 @@ } -def arg_extract(string: str, key_dict: dict) -> dict: +def _arg_extract(string: str, key_dict: dict) -> dict: """ Extract arguments from known command dictionaries. @@ -118,6 +119,7 @@ def arg_extract(string: str, key_dict: dict) -> dict: """ arg_dict = dict() # dict to store found arguments for each key matches: List[Match] = list() # list to store matching keywords + string = str(string) # typecasting to prevent TypeError for key in key_dict.keys(): # look for each key in the dictionary match = re.search(key, string) # regex search for key in string @@ -162,7 +164,7 @@ def arg_extract(string: str, key_dict: dict) -> dict: arg = string[match_end:] # special case for comments where everything coming after match is arg if key_dict[key] is not None: # check for nested commands - arg = arg_extract(arg, key_dict[key]) # call arg_extract through recursion + arg = _arg_extract(arg, key_dict[key]) # call _arg_extract through recursion # save matches found outside of comments, not applying for comments if match.end() <= comment_begin or key == ";": @@ -171,35 +173,46 @@ def arg_extract(string: str, key_dict: dict) -> dict: return arg_dict -def read_gcode_to_dict_list(filepath: pathlib.Path) -> List[dict]: +def _read_gcode_to_dict_list(filepath: pathlib.Path) -> List[dict]: """ Read gcode from .gcode file. Args: - filename: (Path) filepath of the .gcode file + filepath: (Path) filepath of the .gcode file Returns: dict_list: (list[dict]) list with every line as dict """ - custom_print("Parsing the gcode...") dict_list = [] + # First pass to count total lines + with open(file=filepath) as file_gcode: + total_lines = sum(1 for _ in file_gcode) + + # Initialize progress bar with the total number of lines + progress_bar = ProgressBar(name=f"Parsing {total_lines} lines of {filepath.name}") + + # Second pass to process the lines with open(file=filepath) as file_gcode: for i, line in enumerate(file_gcode): - line_dict = arg_extract(string=line, key_dict=known_commands) + line_dict = _arg_extract(string=line, key_dict=known_commands) line_dict["line_number"] = i + 1 dict_list.append(line_dict) - custom_print(f"Parsing done. {len(dict_list)} lines parsed.") + # Update progress bar + progress = (i + 1) / total_lines + progress_bar.update(progress) + + # custom_print(f"Parsing done. {len(dict_list)} lines parsed.") return dict_list -def dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) -> List[state]: +def _dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) -> List[state]: """ Convert the line dictionary to a state. - Parameters: + Args: line_dict_list: (dict) dict list with commands initial_machine_setup: (dict) dict with initial machine setup [absolute_position, absolute_extrusion, units, initial_position...] @@ -207,8 +220,43 @@ def dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) state_list: (list[state]) all states in a list """ + position_fully_defined = False + + def is_position_fully_defined(virtual_machine: dict) -> bool: + nonlocal position_fully_defined + + if position_fully_defined or all(virtual_machine[key] is not None for key in ax_keys): + position_fully_defined = True + return position_fully_defined + + def apply_pos_offset(virtual_machine: dict) -> list[float]: + pos = [] + for key in ax_keys: + pos.append(virtual_machine[key] + virtual_machine[f"_{key}"] if virtual_machine[key] is not None else None) + return pos + + def apply_extrusion(line_dict: dict, virtual_machine: dict, command: str) -> dict: + e_value = line_dict[command]["E"] + + # volumetric to length conversion + # (1) V = (d/2)^2 * pi * E + # (2) E = V / ((d/2)^2 * pi) + if virtual_machine.get("volumetric_extrusion", False): + # volumetric extrusion + e_value = e_value / (math.pi * (virtual_machine["filament_diam"] / 2) ** 2) + + if virtual_machine["absolute_extrusion"]: + virtual_machine["E"] = e_value + else: + virtual_machine["E"] = virtual_machine["E"] + e_value + + return virtual_machine + state_list: List[state] = list() + pos_keys = ["X", "Y", "Z"] + ax_keys = pos_keys + ["E"] # add E for extrusion + virtual_machine = { "X": 0, # machine coordinates "Y": 0, @@ -225,43 +273,38 @@ def dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) layer_counter = 0 # overwrite default values from initial machine setup - """TODO: depending on the setting the user should be informed that a default value is used. - I prepared a warning below. - Are all these settings necessary?""" for key in default_virtual_machine: if initial_machine_setup is not None and key in initial_machine_setup: virtual_machine[key] = initial_machine_setup[key] else: - """print( + custom_print( f"The parameter '{key}' was not specified in your machine presets. " - f"Using the the default value of '{default_virtual_machine[key]}' to continue." - )""" + f"Using the the default value of '{default_virtual_machine[key]}' to continue.", + lvl=3, + ) virtual_machine[key] = default_virtual_machine[key] - # initial state creation - state_position = position( - virtual_machine["X"] + virtual_machine["_X"], - virtual_machine["Y"] + virtual_machine["_Y"], - virtual_machine["Z"] + virtual_machine["_Z"], - virtual_machine["E"] + virtual_machine["_E"], - ) - - p_settings = state.p_settings( - speed=virtual_machine["p_vel"], - p_acc=virtual_machine["p_acc"], - jerk=virtual_machine["jerk"], - vX=virtual_machine["vX"], - vY=virtual_machine["vY"], - vZ=virtual_machine["vZ"], - vE=virtual_machine["vE"], - ) - new_state = state(state_position=state_position, state_p_settings=p_settings) # create new state - - # add initial state comment - new_state.comment = "Initial state created by pyGCD." - new_state.line_number = None - - state_list.append(new_state) + # create initial state only with initial position + if not any([virtual_machine[poskey] is None for poskey in pos_keys]): + # initial state creation + state_position = position(apply_pos_offset(virtual_machine)) + + p_settings = state.p_settings( + speed=virtual_machine["p_vel"], + p_acc=virtual_machine["p_acc"], + jerk=virtual_machine["jerk"], + vX=virtual_machine["vX"], + vY=virtual_machine["vY"], + vZ=virtual_machine["vZ"], + vE=virtual_machine["vE"], + ) + new_state = state(state_position=state_position, state_p_settings=p_settings) # create new state + + # add initial state comment + new_state.comment = "Initial state created by pyGCD." + new_state.line_number = None + + state_list.append(new_state) # GCode functionality: for line_dict in line_dict_list: @@ -284,24 +327,27 @@ def dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) virtual_machine["units"] = "SI (mm)" # position & velocity - pos_keys = ["X", "Y", "Z"] movement_commands = ["G0", "G1"] for command in movement_commands: # treat G0 and G1 the same if command in line_dict: # look for xyz movement commands and apply abs/rel for key in pos_keys: if key in line_dict[command]: + # absolute movement if virtual_machine["absolute_position"] is True: virtual_machine[key] = line_dict[command][key] - elif virtual_machine["absolute_position"] is False: # redundant - virtual_machine[key] = virtual_machine[key] + line_dict[command][key] + # relative movement + else: + if is_position_fully_defined(virtual_machine): + virtual_machine[key] = virtual_machine[key] + line_dict[command][key] + else: + raise ValueError("Position is not fully defined, cannot apply relative movement.") # look for extrusion commands and apply abs/rel if "E" in line_dict[command]: - if virtual_machine["absolute_extrusion"] is True: - virtual_machine["E"] = line_dict[command]["E"] - if virtual_machine["absolute_extrusion"] is False: # redundant - virtual_machine["E"] = virtual_machine["E"] + line_dict[command]["E"] + virtual_machine = apply_extrusion(line_dict, virtual_machine, command) + + # feed rates in unit/min to unit/sec if "F" in line_dict[command]: virtual_machine["p_vel"] = line_dict[command]["F"] / 60 @@ -309,8 +355,10 @@ def dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) if "G92" in line_dict: for key in line_dict["G92"]: if key in known_commands["G92"]: - virtual_machine["_" + key] = ( - virtual_machine[key] + line_dict["G92"][key] + virtual_machine["_" + key] + if virtual_machine[key] is None: + virtual_machine[key] = 0 # initialize to 0 if no position is set beforehand + virtual_machine[f"_{key}"] = ( + virtual_machine[key] + line_dict["G92"][key] + virtual_machine[f"_{key}"] ) virtual_machine[key] = line_dict["G92"][key] @@ -336,12 +384,22 @@ def dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) if "S" in line_dict["G4"]: pause_duration = line_dict["G4"]["S"] - state_position = position( - virtual_machine["X"] + virtual_machine["_X"], - virtual_machine["Y"] + virtual_machine["_Y"], - virtual_machine["Z"] + virtual_machine["_Z"], - virtual_machine["E"] + virtual_machine["_E"], - ) + # Ensure all axes are defined if any position is set + if any(virtual_machine[key] is not None for key in ax_keys) and not is_position_fully_defined(virtual_machine): + virtual_machine.update({key: virtual_machine.get(key, 0) or 0 for key in ax_keys}) + custom_print( + "Implicit zero position assumed for axes: '" + + ", ".join(key for key in ax_keys if virtual_machine[key] == 0) + + "' to fully define position." + ) + + state_position = position(apply_pos_offset(virtual_machine)) + # position( + # virtual_machine["X"] + virtual_machine["_X"], + # virtual_machine["Y"] + virtual_machine["_Y"], + # virtual_machine["Z"] + virtual_machine["_Z"], + # virtual_machine["E"] + virtual_machine["_E"], + # ) p_settings = state.p_settings( speed=virtual_machine["p_vel"], @@ -378,7 +436,7 @@ def dict_list_traveler(line_dict_list: List[dict], initial_machine_setup: dict) return state_list -def check_for_unsupported_commands(line_dict_list: dict) -> dict: +def _check_for_unsupported_commands(line_dict_list: dict) -> dict: """Search for unsupported commands used in the G-code, warn the user and return the occurrences. Args: @@ -400,9 +458,10 @@ def check_for_unsupported_commands(line_dict_list: dict) -> dict: } if unsupported_commands_found != []: - custom_print(f"⚠️ Warning: {len(unsupported_command_counts.keys())} known but unsupported command(s) found:") - for key, value in unsupported_command_counts.items(): - custom_print(f" - Command '{key}' found {value} time(s).") + commands_str = ", ".join([f"'{key}' ({value} time(s))" for key, value in unsupported_command_counts.items()]) + custom_print( + f"⚠️ {len(unsupported_command_counts.keys())} known but unsupported command(s) found: {commands_str}", lvl=1 + ) else: custom_print("Great, the G-code does not contain any unsupported commands known to pyGCD 🎈.") @@ -419,8 +478,8 @@ def generate_states(filepath: pathlib.Path, initial_machine_setup: dict) -> List Returns: states: (list[states]) all states in a list """ - line_dict_list = read_gcode_to_dict_list(filepath=filepath) - check_for_unsupported_commands(line_dict_list=line_dict_list) - states = dict_list_traveler(line_dict_list=line_dict_list, initial_machine_setup=initial_machine_setup) + line_dict_list = _read_gcode_to_dict_list(filepath=filepath) + _check_for_unsupported_commands(line_dict_list=line_dict_list) + states = _dict_list_traveler(line_dict_list=line_dict_list, initial_machine_setup=initial_machine_setup) return states diff --git a/pyGCodeDecode/tools.py b/pyGCodeDecode/tools.py index ac79908..d1c4370 100644 --- a/pyGCodeDecode/tools.py +++ b/pyGCodeDecode/tools.py @@ -28,8 +28,10 @@ def save_layer_metrics( Layers are detected using the given layer cue. """ # check if a layer cue was specified - if "layer_cue" not in simulation.initial_machine_setup: - custom_print("⚠️ No layer_cue was specified in the simulation setup. Therefore, layer metrics can not be saved!") + if "layer_cue" not in simulation.initial_machine_setup_dict: + custom_print( + "⚠️ No layer_cue was specified in the simulation setup. Therefore, layer metrics can not be saved!", lvl=1 + ) return None if locale is None: @@ -92,7 +94,7 @@ def save_layer_metrics( header=header, comments="", ) - custom_print(f"πŸ’Ύ Layer metrics written to:\nπŸ‘‰ {filepath.__str__()}") + custom_print(f"πŸ’Ύ Layer metrics written to πŸ‘‰ {filepath.__str__()}") return layers, durations, travel_distances, avg_speeds @@ -166,7 +168,7 @@ def get_plane_orig(self): return [X_pos, X_neg, Y_pos, Y_neg, Z_pos, Z_neg] - def point_eval(point, pl_lim): + def _point_eval(point, pl_lim): p_eval = [] for lim_n, p_n in zip(pl_lim, point): inters_pl = [ @@ -176,10 +178,10 @@ def point_eval(point, pl_lim): p_eval.append(inters_pl) return p_eval - def point_inside(p_eval): + def _point_inside(p_eval): return all([all(p_ev_ax) for p_ev_ax in p_eval]) - def intersect_possible(p_eval0, p_eval1): + def _intersect_possible(p_eval0, p_eval1): possible = False for ax_eval0, ax_eval1 in zip(p_eval0, p_eval1): if ax_eval0 != ax_eval1: @@ -194,7 +196,7 @@ def intersect_possible(p_eval0, p_eval1): return possible - def isect_line_plane(p0, p1, p_co, p_no, epsilon=1e-6): + def _isect_line_plane(p0, p1, p_co, p_no, epsilon=1e-6): """Return a Vector or None (when the intersection can't be found). p0, p1: Define the line. @@ -237,19 +239,19 @@ def isect_line_plane(p0, p1, p_co, p_no, epsilon=1e-6): timetable = [] for block in simulation.blocklist: - p_eval_A = point_eval(block.state_A.state_position.get_vec(), control_volume.get_plane_lim()) - p_eval_B = point_eval(block.state_B.state_position.get_vec(), control_volume.get_plane_lim()) + p_eval_A = _point_eval(block.state_A.state_position.get_vec(), control_volume.get_plane_lim()) + p_eval_B = _point_eval(block.state_B.state_position.get_vec(), control_volume.get_plane_lim()) - if intersect_possible(p_eval0=p_eval_A, p_eval1=p_eval_B): + if _intersect_possible(p_eval0=p_eval_A, p_eval1=p_eval_B): for plane_orig, plane_normal in zip(control_volume.get_plane_orig(), control_volume.get_plane_normals()): - isec, s_len, sgn = isect_line_plane( + isec, s_len, sgn = _isect_line_plane( p0=block.state_A.state_position.get_vec(), p1=block.state_B.state_position.get_vec(), p_co=plane_orig, p_no=plane_normal, ) - if isec is not None and point_inside(p_eval=point_eval(isec, control_volume.get_plane_lim())): + if isec is not None and _point_inside(p_eval=_point_eval(isec, control_volume.get_plane_lim())): timetable.append([float(block.inverse_time_at_pos(s_len)), sgn]) timetable = np.asarray(timetable) # convert list to array for sorting diff --git a/pyGCodeDecode/utils.py b/pyGCodeDecode/utils.py index 5405adb..4722b83 100644 --- a/pyGCodeDecode/utils.py +++ b/pyGCodeDecode/utils.py @@ -1,5 +1,5 @@ """ -Utilitys. +Utilities. Utils for the GCode Reader contains: - vector 4D @@ -7,10 +7,57 @@ - position """ -from typing import List +from typing import TYPE_CHECKING, List, Optional, Union import numpy as np +if TYPE_CHECKING: + from pyGCodeDecode.state import state + + +class seconds(float): + """A float subclass representing a time duration in seconds. + + Args: + value (float or int): The time duration in seconds. + Examples: + ```python + >>> from pyGCodeDecode.utils import seconds + >>> t = seconds(5) + >>> str(t) + '5.0 s' + >>> t.seconds + 5.0 + ``` + """ + + """Time class for storing time, behaves like a float with additional methods.""" + + def __new__(cls, value): + """Create a new instance of seconds.""" + return float.__new__(cls, value) + + def __str__(self) -> str: + """Return string representation of the time in seconds.""" + return f"{float(self)} s" + + def __sub__(self, other) -> "seconds": + """Subtract seconds or float and return a new seconds instance.""" + return seconds(float(self) - float(other)) + + def __add__(self, other) -> "seconds": + """Add seconds or float and return a new seconds instance.""" + return seconds(float(self) + float(other)) + + def __repr__(self) -> str: + """Return a string representation of the seconds object.""" + return self.__str__() + + @property + def seconds(self) -> float: + """Return the float value of the seconds instance.""" + return float(self) + class vector_4D: """The vector_4D class stores 4D vector in x,y,z,e. @@ -36,9 +83,9 @@ def __init__(self, *args): self.z = None self.e = None - if type(args) is tuple and len(args) == 1: + if isinstance(args[0], (tuple, list, np.ndarray)) and len(args) == 1 and len(args[0]) == 4: args = tuple(args[0]) - if type(args) is tuple and len(args) == 4: + if len(args) == 4: self.x = args[0] self.y = args[1] self.z = args[2] @@ -50,6 +97,10 @@ def __str__(self) -> str: """Return string representation.""" return f"[{self.x}, {self.y}, {self.z}, {self.e}]" + def __repr__(self): + """Return a string representation of the 4D vector.""" + return self.__str__() + def __add__(self, other): """Add functionality for 4D vectors. @@ -74,6 +125,7 @@ def __add__(self, other): else: raise ValueError( "Addition with __add__ is only possible with other 4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray'" + f" got {type(other)} instead." ) def __sub__(self, other): @@ -111,7 +163,7 @@ def __mul__(self, other): Returns: mul: (self) scalar multiplication, scaling """ - if type(other) is float or type(other) is np.float64 or type(other) is int: + if isinstance(other, (float, int, np.floating, np.integer)): x = self.x * other y = self.y * other z = self.z * other @@ -129,7 +181,7 @@ def __truediv__(self, other): Returns: div: (self) scalar division, scaling """ - if type(other) is float or type(other) is np.float64: + if isinstance(other, (float, int, np.floating, np.integer)): x = self.x / other y = self.y / other z = self.z / other @@ -138,34 +190,45 @@ def __truediv__(self, other): raise TypeError("Division of 4D Vectors only supports float and int.") return self.__class__(x, y, z, e) - def __eq__(self, other): + def __eq__(self, other) -> bool: """Check for equality and return True if equal. Args: other: (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray') Returns: - eq: (bool) true if equal (with tolerance) + eq: (bool) true if equal """ if isinstance(other, type(self)): - if ( - np.isclose(self.x, other.x) - and np.isclose(self.y, other.y) - and np.isclose(self.z, other.z) - and np.isclose(self.e, other.e) - ): - return True + other_vec = [other.x, other.y, other.z, other.e] + elif isinstance(other, (np.ndarray, list, tuple)) and len(other) == 4: + other_vec = list(other) + else: + return False - elif (isinstance(other, np.ndarray) or isinstance(other, list) or isinstance(other, tuple)) and len(other) == 4: - if ( - np.isclose(self.x, other[0]) - and np.isclose(self.y, other[1]) - and np.isclose(self.z, other[2]) - and np.isclose(self.e, other[3]) - ): - return True + self_vec = [self.x, self.y, self.z, self.e] + return other_vec == self_vec + # return np.allclose(self_vec, other_vec) + + def __gt__(self, other) -> bool: + """Check for greater than and return True if greater. + + Args: + other: (4D vector, 1x4 'list', 1x4 'tuple' or 1x4 'numpy.ndarray') - def get_vec(self, withExtrusion=False) -> List[float]: + Returns: + gt: (bool) true if greater + """ + if isinstance(other, type(self)): + return self.get_norm() > other.get_norm() + elif (isinstance(other, np.ndarray)) or (isinstance(other, (list, tuple)) and len(other) == 4): + return self.get_norm() > np.linalg.norm(other) + elif isinstance(other, (float, int)): + return self.get_norm() > other + else: + return False + + def get_vec(self, withExtrusion: bool = False) -> List[float]: """Return the 4D vector, optionally with extrusion. Args: @@ -179,7 +242,7 @@ def get_vec(self, withExtrusion=False) -> List[float]: else: return [self.x, self.y, self.z] - def get_norm(self, withExtrusion=False) -> float: + def get_norm(self, withExtrusion: bool = False) -> float: """Return the 4D vector norm. Optional with extrusion. Args: @@ -191,70 +254,12 @@ def get_norm(self, withExtrusion=False) -> float: return np.linalg.norm(self.get_vec(withExtrusion=withExtrusion)) -class velocity(vector_4D): - """4D - Velocity object for (Cartesian) 3D printer.""" - - def __str__(self) -> str: - """Print out velocity.""" - return "velocity: " + super().__str__() - - def get_norm_dir(self, withExtrusion=False): - """Get normalized vector (regarding travel distance), if only extrusion occurs, normalize to extrusion length. - - Args: - withExtrusion: (bool, default = False) choose if norm dir contains extrusion - - Returns: - dir: (list[3 or 4]) normalized direction vector as list - """ - abs_val = self.get_norm() - if abs_val > 0: - return self.get_vec(withExtrusion=withExtrusion) / abs_val - elif withExtrusion and self.get_norm(withExtrusion=withExtrusion) > 0: - return self.get_vec(withExtrusion=withExtrusion) / self.get_norm(withExtrusion=withExtrusion) - else: - return None - - def avoid_overspeed(self, p_settings): - """Return velocity without any axis overspeed. - - Args: - p_settings: (p_settings) printing settings - - Returns: - vel: (velocity) constrained by max velocity - """ - scale = 1.0 - scale = p_settings.Vx / self.Vx if self.Vx > 0 and p_settings.Vx / self.Vx < scale else scale - scale = p_settings.Vy / self.Vy if self.Vy > 0 and p_settings.Vy / self.Vy < scale else scale - scale = p_settings.Vz / self.Vz if self.Vz > 0 and p_settings.Vz / self.Vz < scale else scale - scale = p_settings.Ve / self.Ve if self.Vz > 0 and p_settings.Ve / self.Ve < scale else scale - - return self * scale - - def not_zero(self): - """Return True if velocity is not zero. - - Returns: - not_zero: (bool) true if velocity is not zero - """ - return True if np.linalg.norm(self.get_vec(withExtrusion=True)) > 0 else False - - def is_extruding(self): - """Return True if extrusion velocity is not zero. - - Returns: - is_extruding: (bool) true if positive extrusion velocity - """ - return True if self.e > 0 else False - - class position(vector_4D): """4D - Position object for (Cartesian) 3D printer.""" def __str__(self) -> str: """Print out position.""" - return "position: " + super().__str__() + return "Position: " + super().__str__() def is_travel(self, other) -> bool: """Return True if there is travel between self and other position. @@ -287,7 +292,7 @@ def is_extruding(self, other: "position", ignore_retract: bool = True) -> bool: else: return False - def get_t_distance(self, other=None, withExtrusion=False) -> float: + def get_t_distance(self, other=None, withExtrusion: bool = False) -> float: """Calculate the travel distance between self and other position. If none is provided, zero will be used. Args: @@ -303,6 +308,140 @@ def get_t_distance(self, other=None, withExtrusion=False) -> float: np.subtract(self.get_vec(withExtrusion=withExtrusion), other.get_vec(withExtrusion=withExtrusion)) ) + def __truediv__(self, other): + """Divide position by seconds to get velocity.""" + if isinstance(other, seconds): + return velocity( + self.x / other.seconds, + self.y / other.seconds, + self.z / other.seconds, + self.e / other.seconds, + ) + else: + return super().__truediv__(other) + + +class velocity(vector_4D): + """4D - Velocity object for (Cartesian) 3D printer.""" + + def __str__(self) -> str: + """Print out velocity.""" + return "velocity: " + super().__str__() + + def get_norm_dir(self, withExtrusion: bool = False) -> Optional[np.ndarray]: + """Get normalized direction vector as numpy array. + + If only extrusion occurs and withExtrusion=True, normalize to the extrusion length. + + Returns None if both travel and extrusion are zero. + """ + # travel_vec = np.asarray(self.get_vec(withExtrusion=False), dtype=float) + # travel_norm = np.linalg.norm(travel_vec) + travel_norm = self.get_norm() + if travel_norm > 0: + vec = np.asarray(self.get_vec(withExtrusion=withExtrusion), dtype=float) + return vec / travel_norm + elif withExtrusion: + vec_e = np.asarray(self.get_vec(withExtrusion=True), dtype=float) + full_norm = np.linalg.norm(vec_e) + if full_norm > 0: + return vec_e / full_norm + return None + + # def avoid_overspeed(self, p_settings: "state.p_settings") -> "velocity": + # """Return velocity scaled to avoid any axis overspeed. + + # Scales the velocity uniformly so that no axis exceeds its configured maximum. + # """ + # scale = 1.0 + # scale = p_settings.Vx / self.Vx if self.Vx > 0 and (p_settings.Vx / self.Vx) < scale else scale + # scale = p_settings.Vy / self.Vy if self.Vy > 0 and (p_settings.Vy / self.Vy) < scale else scale + # scale = p_settings.Vz / self.Vz if self.Vz > 0 and (p_settings.Vz / self.Vz) < scale else scale + # scale = p_settings.Ve / self.Ve if self.Ve > 0 and (p_settings.Ve / self.Ve) < scale else scale + + # return self * scale + + def not_zero(self) -> bool: + """Return True if velocity is not zero. + + Returns: + not_zero: (bool) true if velocity is not zero + """ + return True if np.linalg.norm(self.get_vec(withExtrusion=True)) > 0 else False + + def is_extruding(self) -> bool: + """Return True if extrusion velocity is greater than zero. + + Returns: + is_extruding: (bool) true if positive extrusion velocity + """ + return True if self.e > 0 else False + + def __mul__(self, other): + """Multiply velocity by a time to get position, or by scalar.""" + if isinstance(other, seconds): + # velocity * seconds = position + return position( + self.x * other.seconds, + self.y * other.seconds, + self.z * other.seconds, + self.e * other.seconds, + ) + elif isinstance(other, (float, int, np.floating, np.integer)): + return self.__class__( + self.x * other, + self.y * other, + self.z * other, + self.e * other, + ) + else: + raise TypeError("Multiplication only supports seconds, float, or int.") + + def __truediv__(self, other): + """Divide velocity by scalar.""" + if isinstance(other, seconds): + # velocity / seconds = acceleration + return acceleration( + self.x / other.seconds, + self.y / other.seconds, + self.z / other.seconds, + self.e / other.seconds, + ) + else: + return super().__truediv__(other) + + +class acceleration(vector_4D): + """4D - Acceleration object for (Cartesian) 3D printer.""" + + def __str__(self) -> str: + """Print out acceleration.""" + return "acceleration: " + super().__str__() + + def __mul__(self, other): + """Multiply acceleration by a time to get velocity, or by scalar.""" + if isinstance(other, seconds): + # acceleration * time = velocity + return velocity( + self.x * other.seconds, + self.y * other.seconds, + self.z * other.seconds, + self.e * other.seconds, + ) + elif isinstance(other, (float, int, np.floating, np.integer)): + return self.__class__( + self.x * other, + self.y * other, + self.z * other, + self.e * other, + ) + else: + raise TypeError("Multiplication only supports seconds, float, or int.") + + def __truediv__(self, other): + """Divide acceleration by scalar.""" + return super().__truediv__(other) + class segment: """Store Segment data for linear 4D Velocity function segment. @@ -315,6 +454,7 @@ class segment: - move_segment_time: moves Segment in time by a specified interval - get_velocity: returns the calculated Velocity for all axis at a given point in time - get_position: returns the calculated Position for all axis at a given point in time + - get_segm_len: returns the length of the segment. **Class method** - create_initial: returns the artificial initial segment where everything is at standstill, intervall length = 0 @@ -323,8 +463,8 @@ class segment: def __init__( self, - t_begin: float, - t_end: float, + t_begin: Union[float, seconds], + t_end: Union[float, seconds], pos_begin: position, vel_begin: velocity, pos_end: position = None, @@ -341,13 +481,15 @@ def __init__( vel_end: (velocity, default = None) ending velocity of segment """ - self.t_begin = t_begin - self.t_end = t_end - self.pos_begin = pos_begin - self.pos_end = pos_end - self.vel_begin = vel_begin - self.vel_end = vel_end - self.self_check() + self.t_begin: seconds = seconds(t_begin) + self.t_end: seconds = seconds(t_end) + self.pos_begin: position = pos_begin + self.pos_end: position = pos_end + self.vel_begin: velocity = vel_begin + self.vel_end: velocity = vel_end + # self.self_check() + + self.result = {} def __str__(self) -> str: """Create string from segment.""" @@ -357,7 +499,7 @@ def __repr__(self): """Segment representation.""" return self.__str__() - def move_segment_time(self, delta_t: float): + def move_segment_time(self, delta_t: Union[float, seconds]) -> None: """Move segment in time. Args: @@ -366,7 +508,7 @@ def move_segment_time(self, delta_t: float): self.t_begin = self.t_begin + delta_t self.t_end = self.t_end + delta_t - def get_velocity(self, t: float) -> velocity: + def get_velocity(self, t: Union[float, seconds]) -> velocity: """Get current velocity of segment at a certain time. Args: @@ -375,17 +517,36 @@ def get_velocity(self, t: float) -> velocity: Returns: current_vel: (velocity) velocity at time t """ + if not isinstance(t, seconds): + t = seconds(t) + if t < self.t_begin or t > self.t_end: raise ValueError("Segment not defined for this point in time.") else: + delt_t = self.t_end - self.t_begin + if delt_t == 0: + return self.vel_begin # linear interpolation of velocity in Segment delt_vel = self.vel_end - self.vel_begin - delt_t = self.t_end - self.t_begin - slope = delt_vel / delt_t if delt_t > 0 else velocity(0, 0, 0, 0) - current_vel = self.vel_begin + slope * (t - self.t_begin) + slope = delt_vel / delt_t + current_vel = self.vel_begin + (slope * (t - self.t_begin)) return current_vel - def get_position(self, t: float) -> position: + def get_velocity_by_dist(self, dist: float) -> float: + """Return the velocity magnitude at a certain local segment distance. + + Args: + dist: (float) distance from segment start + """ + dt = self.t_end - self.t_begin + a = 0.0 if dt == 0 else (self.vel_end.get_norm() - self.vel_begin.get_norm()) / dt + + v_sq = 2 * a * dist + self.vel_begin.get_norm() ** 2 + v = np.sqrt(v_sq) if v_sq > 0 else 0 + + return float(v) + + def get_position(self, t: Union[float, seconds]) -> position: """Get current position of segment at a certain time. Args: @@ -394,48 +555,48 @@ def get_position(self, t: float) -> position: Returns: pos: (position) position at time t """ + if not isinstance(t, seconds): + t = seconds(t) if t < self.t_begin or t > self.t_end: raise ValueError(f"Segment not defined for this point in time. {t} -->({self.t_begin}, {self.t_end})") else: current_vel = self.get_velocity(t=t) - position = self.pos_begin + ((self.vel_begin + current_vel) * (t - self.t_begin) / 2.0).get_vec( - withExtrusion=True - ) - return position + # displacement = average velocity * dt + displacement_vec = ((self.vel_begin + current_vel) * (t - self.t_begin) / 2.0).get_vec(withExtrusion=True) + position_val = self.pos_begin + displacement_vec + return position_val - def get_segm_len(self): + def get_segm_len(self) -> float: """Return the length of the segment.""" return (self.pos_end - self.pos_begin).get_norm() - def get_segm_duration(self): + def get_segm_duration(self) -> seconds: """Return the duration of the segment.""" return self.t_end - self.t_begin - def self_check(self, p_settings=None): # ,, state:state=None): + def self_check(self, p_settings: "state.p_settings" = None) -> bool: """Check the segment for self consistency. - todo: - - max acceleration - + Raises: + ValueError: if self check fails Args: - p_settings: (p_setting, default = None) printing settings to verify + p_settings: (p_settings, default = None) printing settings to verify + Returns: + True if all checks pass """ # position self check: - tolerance = float("1e-6") - position = self.pos_begin + ((self.vel_begin + self.vel_end) * (self.t_end - self.t_begin) / 2.0).get_vec( - withExtrusion=True - ) - error_distance = np.linalg.norm(np.asarray(self.pos_end.get_vec()) - np.asarray(position.get_vec())) - - if error_distance > tolerance: + tolerance = 1e-6 + position_calc = self.pos_begin + ((self.vel_begin + self.vel_end) * (self.t_end - self.t_begin) / 2.0) + error_distance = self.pos_end - position_calc + if error_distance.get_norm(withExtrusion=True) > tolerance: raise ValueError("Error distance: " + str(error_distance)) # time consistency if self.t_begin > self.t_end: raise ValueError(f"Inconsistent segment time (t_begin/t_end): ({self.t_begin}/{self.t_end}) \n ") - # max velocity if p_settings is not None: + # max velocity if self.vel_begin.get_norm() > p_settings.speed and not np.isclose( self.vel_begin.get_norm(), p_settings.speed ): @@ -443,6 +604,30 @@ def self_check(self, p_settings=None): # ,, state:state=None): if self.vel_end.get_norm() > p_settings.speed and not np.isclose(self.vel_end.get_norm(), p_settings.speed): raise ValueError(f"Target Velocity of {p_settings.speed} exceeded with {self.vel_end.get_norm()}.") + # max acceleration + if self.t_end - self.t_begin > 0: + acc = (self.vel_end - self.vel_begin) / (self.t_end - self.t_begin) + + # Scale tolerance based on time delta to handle numerical precision issues + dt = self.t_end - self.t_begin + base_rtol = 1e-5 # Standard relative tolerance + base_atol = 0.1 # Absolute tolerance in mm/sΒ² + + # Scale tolerance inversely with time delta (smaller dt = larger tolerance) + dt_scale = min(1e-6 / max(dt, 1e-12), 1000.0) + scaled_rtol = base_rtol * dt_scale + scaled_atol = base_atol * dt_scale + acc_norm = acc.get_norm() + + if acc_norm > p_settings.p_acc and not np.isclose( + acc_norm, p_settings.p_acc, rtol=scaled_rtol, atol=scaled_atol + ): + raise ValueError( + f"Maximum acceleration of {p_settings.p_acc} exceeded with {acc_norm}. " + f"Delta t: {dt:.2e}, tolerance used: rtol={scaled_rtol:.2e}, atol={scaled_atol:.2e}" + ) + return True + def is_extruding(self) -> bool: """Return true if the segment is pos. extruding. @@ -451,7 +636,7 @@ def is_extruding(self) -> bool: """ return self.pos_begin.e < self.pos_end.e - def _interpolate_time_to_space(self, scalar_begin, scalar_end, x): + def _interpolate_time_to_space(self, scalar_begin, scalar_end, x) -> float: """ Interpolate from linear time dependant to nonlinear space dependant. @@ -481,54 +666,7 @@ def get_time(x): return scalar - def calc_results(self, v_target): - """Calculate and store the segment results. - - Args: - v_target: (float) target velocity - """ - # GLOBAL ERROR METRIC - - def error_integral(a, v_begin, v_target, x_end): - sqr = 2 * a * x_end + v_begin**2 if not np.isclose(self.vel_end.get_norm(), 0.0) else 0 - - integral = ((v_begin**3) + 3 * a * v_target * x_end - (2 * a * x_end + v_begin**2) * np.sqrt(sqr)) / ( - 3 * a * v_target - ) - return integral - - v_begin = self.vel_begin.get_norm() - x_end = self.get_segm_len() # segment length - - delta_t = self.get_segm_duration() - delta_v = self.vel_end.get_norm() - self.vel_begin.get_norm() - - if not np.isclose(v_target, 0.0) and not np.isclose(delta_t, 0.0) and not np.isclose(delta_v, 0.0): - - a = delta_v / delta_t - - # print("vbegin: ", v_begin, "vend", self.vel_end.get_norm(), "vtarget: ", v_target, "xend", x_end ) - avg_error = error_integral(a=a, v_begin=v_begin, v_target=v_target, x_end=x_end) - else: - avg_error = 0 - - self.result.update({"segm_error": [avg_error]}) - - # LOCAL VELOCITY ERROR - - if v_target > 0: - vel_rel_err_begin = (v_target - v_begin) / v_target - vel_rel_err_end = (v_target - self.vel_end.get_norm()) / v_target - else: # if no target vel, error is zero. - vel_rel_err_begin = 0 - vel_rel_err_end = 0 - self.result.update({"rel_vel_err": [vel_rel_err_begin, vel_rel_err_end]}) - - # LOCAL VELOCITY - - self.result.update({"vel": [self.vel_begin.get_norm(), self.vel_end.get_norm()]}) - - def get_result(self, key): + def get_result(self, key: str): """Return the requested result. Args: @@ -538,15 +676,12 @@ def get_result(self, key): result: (list) """ if key in self.result: - if len(self.result[key]) == 2: # linear - return self.result[key] - elif len(self.result[key]) == 1: # constant - return [self.result[key][0], self.result[key][0]] + return self.result[key] else: raise ValueError(f"Key: {key} not found.") @classmethod - def create_initial(cls, initial_position: position = None): + def create_initial(cls, initial_position: Optional[position] = None) -> "segment": """Create initial static segment with (optionally) initial position else start from Zero. Args: @@ -556,5 +691,5 @@ def create_initial(cls, initial_position: position = None): segment: (segment) initial beginning segment """ velocity_0 = velocity(0, 0, 0, 0) - pos_0 = position(x=0, y=0, z=0, e=0) if initial_position is None else initial_position + pos_0 = position(0, 0, 0, 0) if initial_position is None else initial_position return cls(t_begin=0, t_end=0, pos_begin=pos_0, vel_begin=velocity_0, pos_end=pos_0, vel_end=velocity_0) diff --git a/pydoc-markdown.yml b/pydoc-markdown.yml new file mode 100644 index 0000000..2e1744e --- /dev/null +++ b/pydoc-markdown.yml @@ -0,0 +1,72 @@ +loaders: + - type: python + search_path: [.] + packages: ["pyGCodeDecode"] + +processors: + - type: filter + expression: not '.examples.' in name # Skip example modules + - type: filter + documented_only: true # Skip empty modules + - type: smart + - type: crossref + +renderer: + # type: markdown + type: mkdocs + output_directory: docs + markdown: + insert_header_anchors: true + render_page_title: true + toc_maxdepth: 4 + render_toc: false + use_fixed_header_levels: true + add_module_prefix: true + add_method_class_prefix: true + add_member_class_prefix: true + header_level_by_type: + Module: 2 + Class: 3 + Function: 4 + + pages: + - title: Home + name: index + source: README.md + - title: pyGCodeDecode API Reference + name: api + contents: + - pyGCodeDecode.* + # - pyGCodeDecode.cli + # - pyGCodeDecode.gcode_interpreter + # - pyGCodeDecode.abaqus_file_generator + # - pyGCodeDecode.plotter + # - pyGCodeDecode.tools + # - pyGCodeDecode.result + # - pyGCodeDecode.helpers + # - pyGCodeDecode.junction_handling + # - pyGCodeDecode.planner_block + # - pyGCodeDecode.state + # - pyGCodeDecode.state_generator + # - pyGCodeDecode.utils + mkdocs_config: + site_name: pyGCodeDecode Documentation + site_description: Generate time dependent boundary conditions from a .gcode file + site_author: FAST-LB at KIT + repo_url: https://github.com/FAST-LB/pyGCodeDecode + repo_name: FAST-LB/pyGCodeDecode + edit_uri: edit/main/docs/ + theme: + name: readthedocs + highlightjs: true + nav: + - Home: index.md + - API Reference: api.md + markdown_extensions: + - toc: + permalink: true + - codehilite + - admonition + - tables + plugins: + - search diff --git a/pyproject.toml b/pyproject.toml index cc6ca65..391bb5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ where = ["."] [project] name = "pyGCodeDecode" -version = "1.3.1" +version = "1.4.0" authors = [ { name = "FAST-LB at KIT", email = "lt-github@fast.kit.edu" }, { name = "Jonathan Knirsch", email = "jonathan.knirsch@student.kit.edu" }, @@ -37,7 +37,7 @@ requires-python = ">=3.9" dependencies = ["numpy", "matplotlib", "PyYAML", "pyvista"] [project.optional-dependencies] -TEST = ["pytest-cov"] +TEST = ["pytest-cov", "pandas"] DEVELOPER = [ "flake8", "isort", @@ -46,6 +46,10 @@ DEVELOPER = [ "pre-commit", "pytest-cov", ] +DOCS = [ + "mkdocs", + "pydoc-markdown @ git+https://github.com/jeknirsch/pydoc-markdown.git@sort_modules", # using custom fork to sort modules +] [project.urls] Code = "https://github.com/FAST-LB/pyGCodeDecode" @@ -56,6 +60,9 @@ pygcd = "pyGCodeDecode.cli:_main" [tool.black] line-length = 120 +extend-exclude = ''' +^.*\.ipynb$ +''' [tool.isort] profile = "black" @@ -69,15 +76,3 @@ omit = ["./tests/*", "./example/*"] [tool.coverage.report] # Regexes for lines to exclude from consideration exclude_also = ["if __name__ == .__main__.:", "except", "import"] - -[[tool.pydoc-markdown.loaders]] -type = "python" -search_path = ["."] - -[tool.pydoc-markdown.renderer] -type = "mkdocs" - -[[tool.pydoc-markdown.renderer.pages]] -title = "API Documentation" -name = "index" -contents = ["pyGCodeDecode.*"] diff --git a/tests/data/test_printer_setup_single.yaml b/tests/data/test_printer_setup_single.yaml new file mode 100644 index 0000000..5d40fbe --- /dev/null +++ b/tests/data/test_printer_setup_single.yaml @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +debugging: + # general properties + nozzle_diam: 0.4 + filament_diam: 1.75 + # default settings + p_vel: 85 + p_acc: 100 + jerk: 8 + # axis max speeds + vX: 180 + vY: 170 + vZ: 12 + vE: 80 + firmware: marlin diff --git a/tests/data/test_printer_setups.yaml b/tests/data/test_printer_setups.yaml index 613d728..7f5f135 100644 --- a/tests/data/test_printer_setups.yaml +++ b/tests/data/test_printer_setups.yaml @@ -13,7 +13,7 @@ prusa_mini: vY: 180 vZ: 12 vE: 80 - firmware: marlin_jd + firmware: prusa test: # general properties @@ -28,7 +28,7 @@ test: vY: 180 vZ: 30 vE: 33 - firmware: marlin_jd + firmware: junction_deviation debugging: # general properties diff --git a/tests/data/test_simplest.gcode b/tests/data/test_simplest.gcode new file mode 100644 index 0000000..5d98171 --- /dev/null +++ b/tests/data/test_simplest.gcode @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e50ac8a0ed03bef69b4ec17e49ee941ae6a03c54ede9293228264d3dc008c8a +size 59 diff --git a/tests/self_test/self_test.gcode b/tests/self_test/self_test.gcode new file mode 100644 index 0000000..0a1c601 --- /dev/null +++ b/tests/self_test/self_test.gcode @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d9ccd739beb37cfc3c10a17db1420c3a2b9bbc15bf55de0f83e2aacd821c68a +size 91 diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 49a2568..4bee3b6 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -2,6 +2,10 @@ import pathlib +import numpy as np + +from pyGCodeDecode.plotter import plot_vel + def test_end_to_end_compact(): """Testing the simulation functionality with automatic setup, similarly to the brace example.""" @@ -13,10 +17,44 @@ def test_end_to_end_compact(): ) +def test_end_to_end_volumetr(): + """Testing the simulation functionality with automatic setup, using volumetric or distance based extrusion.""" + from pyGCodeDecode.gcode_interpreter import setup, simulation + + preset = setup(pathlib.Path("./tests/data/test_printer_setups.yaml"), "test") + preset.set_property({"volumetric_extrusion": False}) + + sim = simulation( + gcode_path=pathlib.Path("./tests/data/test_state_generator.gcode"), + initial_machine_setup=preset, + ) + + end_extrusion = sim.blocklist[-1].segments[-1].pos_end.e + expected_extrusion = 14.0 + assert np.isclose( + end_extrusion, expected_extrusion, rtol=1e-4 + ), f"Expected {expected_extrusion}, but got {end_extrusion}" + preset = setup(pathlib.Path("./tests/data/test_printer_setups.yaml"), "test") + preset.set_property({"volumetric_extrusion": True}) + + sim = simulation( + gcode_path=pathlib.Path("./tests/data/test_state_generator.gcode"), + initial_machine_setup=preset, + ) + + end_extrusion = sim.blocklist[-1].segments[-1].pos_end.e + expected_extrusion = 14.0 / ((1.75 / 2) ** 2 * np.pi) + + assert np.isclose( + end_extrusion, expected_extrusion, rtol=1e-4 + ), f"Expected {expected_extrusion}, but got {end_extrusion}" + + def test_end_to_end_extensive(): """Testing the simulation functionality as well as the various outputs, similarly to the benchy example.""" from pyGCodeDecode.abaqus_file_generator import generate_abaqus_event_series from pyGCodeDecode.gcode_interpreter import setup, simulation + from pyGCodeDecode.plotter import plot_2d, plot_3d from pyGCodeDecode.tools import save_layer_metrics data_dir = pathlib.Path("./tests/data/") @@ -40,6 +78,7 @@ def test_end_to_end_extensive(): gcode_path=data_dir / "test_state_generator.gcode", initial_machine_setup=printer_setup, output_unit_system="SI (mm)", + verbosity_level=3, ) # save a short summary of the simulation @@ -60,18 +99,37 @@ def test_end_to_end_extensive(): output_unit_system="SI (mm)", ) - # create a 3D-plot and save a VTK as well as a screenshot - end_to_end_simulation.plot_3d( - extrusion_only=True, + # create a 3D-plot + plot_3d( + sim=end_to_end_simulation, screenshot_path=output_dir / "test_end_to_end_3d.png", vtk_path=output_dir / "test_end_to_end.vtk", + camera_settings={"camera_position": "xy"}, + ) + + # 3D-plot with matplotlib + plot_3d( + sim=end_to_end_simulation, + screenshot_path=output_dir / "test_end_to_end_3d_mpl.png", + vtk_path=output_dir / "test_end_to_end.vtk", + camera_settings={"camera_position": "xy"}, + mpl_subplot=True, ) # create a 2D-plot with matplotlib - end_to_end_simulation.plot_2d_position(filepath=output_dir / "test_end_to_end_2d.png", show=False) + plot_2d( + sim=end_to_end_simulation, + show=False, + filepath=output_dir / "test_end_to_end_2d.png", + ) + # end_to_end_simulation.plot_2d_position(filepath=output_dir / "test_end_to_end_2d.png", show=False) # plotting velocities with matplotlib - end_to_end_simulation.plot_vel(show=False, filepath=output_dir / "velocities.png") + plot_vel( + sim=end_to_end_simulation, + show=False, + filepath=output_dir / "velocities.png", + ) # assert that the output files exists (pyvista screenshot cannot be created without a display) output_files = [ diff --git a/tests/test_gcode_interpreter.py b/tests/test_gcode_interpreter.py index 822a778..ae4b93a 100644 --- a/tests/test_gcode_interpreter.py +++ b/tests/test_gcode_interpreter.py @@ -42,10 +42,77 @@ def test_setup(): assert sim_dict["Z"] == 3 assert sim_dict["E"] == 4 - # change printer afterwards - simulation_setup.select_printer("prusa_mini") + +def test_setup_extended(): + """Test for the simulation setup class.""" + from pyGCodeDecode.gcode_interpreter import setup + + # test for specific printer is selected + simulation_setup = setup(presets_file=pathlib.Path("./tests/data/test_printer_setups.yaml"), printer="test") + assert simulation_setup.printer == "test" + assert simulation_setup.firmware == "junction_deviation" + + # test for multiple printers in file and none has been selected -> error expected + try: + simulation_setup = setup( + presets_file=pathlib.Path("./tests/data/test_printer_setups.yaml"), + ) + assert False, "Expected ValueError was not raised." + except ValueError as e: + assert str(e) == "Multiple printers found but none has been selected." + + # test for single printer in file and none has been selected -> auto select + simulation_setup = setup( + presets_file=pathlib.Path("./tests/data/test_printer_setup_single.yaml"), verbosity_level=4 + ) + assert simulation_setup.printer == "debugging" + + # test for custom properties setting + simulation_setup = setup( + presets_file=pathlib.Path("./tests/data/test_printer_setup_single.yaml"), + verbosity_level=4, + extra_property=12, + ) + assert simulation_setup.printer == "debugging" + + # test the accessors + assert simulation_setup.extra_property == 12 + assert simulation_setup.get_dict()["extra_property"] == 12 + + # test the setters + simulation_setup.extra_property = 14 + simulation_setup.set_property({"special_property": 9}) + + assert simulation_setup.extra_property == 14 + assert simulation_setup.get_dict()["extra_property"] == 14 + assert simulation_setup.get_dict()["special_property"] == 9 + assert simulation_setup.special_property == 9 + + # test if printer settings are correctly loaded + sim_dict = simulation_setup.get_dict() + assert sim_dict["p_vel"] == 85 + assert sim_dict["p_acc"] == 100 + assert sim_dict["jerk"] == 8 + + assert sim_dict["vX"] == 180 + assert sim_dict["vY"] == 170 + assert sim_dict["vZ"] == 12 + assert sim_dict["vE"] == 80 + + assert sim_dict["nozzle_diam"] == 0.4 + assert sim_dict["filament_diam"] == 1.75 + + assert sim_dict["X"] == 0 + assert sim_dict["Y"] == 0 + assert sim_dict["Z"] == 0 + assert sim_dict["E"] == 0 + + simulation_setup.set_initial_position((1, 2, 3, 4)) sim_dict = simulation_setup.get_dict() - assert sim_dict["p_acc"] == 1250 + assert sim_dict["X"] == 1 + assert sim_dict["Y"] == 2 + assert sim_dict["Z"] == 3 + assert sim_dict["E"] == 4 def test_simulation_class(): diff --git a/tests/test_junction_handling.py b/tests/test_junction_handling.py new file mode 100644 index 0000000..2513c30 --- /dev/null +++ b/tests/test_junction_handling.py @@ -0,0 +1,249 @@ +"""Test for the junction handling.""" + +import copy +import math +import os +import pathlib +import sys + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +from pyGCodeDecode.gcode_interpreter import generate_planner_blocks +from pyGCodeDecode.junction_handling import ( + _get_handler_names, + get_handler, + junction_handling, +) +from pyGCodeDecode.state import state +from pyGCodeDecode.state_generator import generate_states +from pyGCodeDecode.utils import position + + +def _rotate_pos(pos: position, alpha): # 2D Rotation + alpha_r = math.radians(alpha) + pos_v = np.array(pos.get_vec()[:2]) + rot_M = np.array([[math.cos(alpha_r), -math.sin(alpha_r)], [math.sin(alpha_r), math.cos(alpha_r)]]) + new_pos = np.matmul(rot_M, pos_v) + return new_pos + + +def _rotate_state(state, alpha=45): + pos = state.state_position + new_pos = _rotate_pos(pos, alpha) + state.state_position = position(new_pos[0], new_pos[1], pos.z, pos.e) + + return state + + +def _initialize_dummy_states(p_acc, jerk, speed): + settings = state.p_settings(p_acc=p_acc, jerk=jerk, vX=100, vY=100, vZ=100, vE=100, speed=speed) + stateA = state(state_p_settings=settings) + stateB = state(state_p_settings=settings) + stateC = state(state_p_settings=settings) + + stateA.next_state = stateB + stateB.prev_state = stateA + stateB.next_state = stateC + stateC.prev_state = stateB + return stateA, stateB, stateC + + +def test_junction_handlings(): + """Test for junction handling.""" + # Test cases for junction handling + test_firmwares = _get_handler_names() + test_firmwares.append("unknown") # Add "unknown" for default handler + print(f"Testing the following firmwares: {test_firmwares}", file=sys.stderr) + + angles = np.arange(0, 181, 1) + results = {"turning angle": angles} + for firmware in test_firmwares: + handler = get_handler(firmware) + assert handler is not None + assert handler is not junction_handling if firmware != "unknown" else handler is junction_handling + assert callable(handler) + + stateA, stateB, stateC = _initialize_dummy_states(p_acc=1000, jerk=10, speed=50) + stateA.state_position = position(0, 0, 0, 0) + stateB.state_position = position(50, 0, 0, 0) + + ang = 0 + stateA = _rotate_state(stateA, ang) + stateB = _rotate_state(stateB, ang) + + firmware_results = [] + for angle in angles: + coordXY = [math.cos(math.radians(angle)) * 50 + 50, math.sin(math.radians(angle)) * 50] + stateC.state_position = position(*coordXY, 0, 0) + stateC = _rotate_state(stateC, ang) + + result = handler(state_A=stateA, state_B=stateB).get_junction_vel() + firmware_results.append(result) + results[firmware] = firmware_results + + # Convert to DataFrame + df = pd.DataFrame(results) + + fig, ax = plt.subplots(figsize=(6, 4)) + # Plotting with Pandas built-in functions + df.plot( + ax=ax, + x="turning angle", + y=test_firmwares, + ) + + ax.set_ylabel(r"cornering velocity in $\frac{\mathrm{mm}}{\mathrm{s}}$") + ax.set_xlabel(r"turning angle in $\deg$") + ax.set_xticks(range(0, 181, 30)) + ax.set_yticks(range(0, 51, 5)) + ax.grid(visible=True, which="major", axis="both", alpha=0.8, linestyle="dotted") + + fig.savefig("tests/output/junction_handlings.png", dpi=300, bbox_inches="tight") + # plt.show() + + +def test_junction_handlings_rotating_COS(): + """Test for junction handling with coordinate system rotation.""" + test_firmwares = _get_handler_names() + test_firmwares.append("unknown") # Add "unknown" for default handler + print(f"Testing the following firmwares: {test_firmwares}", file=sys.stderr) + + angles = np.arange(0, 181, 1) + results = {"turning angle": angles} + + cos_rot_angles = np.arange(0, 360, 5) + rotated_results = {"turning angle": angles} + + for firmware in test_firmwares: + handler = get_handler(firmware) + assert handler is not None + assert handler is not junction_handling if firmware != "unknown" else handler is junction_handling + assert callable(handler) + + stateA, stateB, stateC = _initialize_dummy_states(p_acc=1000, jerk=10, speed=50) + stateA.state_position = position(0, 0, 0, 0) + stateB.state_position = position(50, 0, 0, 0) + + ang = 0 + stateA = _rotate_state(stateA, ang) + stateB = _rotate_state(stateB, ang) + + firmware_results = [] + rotated_firmware_results = [] + + for angle in angles: + coordXY = [math.cos(math.radians(angle)) * 50 + 50, math.sin(math.radians(angle)) * 50] + stateC.state_position = position(*coordXY, 0, 0) + stateC = _rotate_state(stateC, ang) + + # Unrotated + result = handler(state_A=stateA, state_B=stateB).get_junction_vel() + firmware_results.append(result) + + # Rotated: rotate all positions by rot_angle + rotations = [] + for rot_angle in cos_rot_angles: + # Deepcopy states to avoid mutation + stateA_r = copy.deepcopy(stateA) + stateB_r = copy.deepcopy(stateB) + stateC_r = copy.deepcopy(stateC) + stateA_r.next_state = stateB_r + stateB_r.prev_state = stateA_r + stateB_r.next_state = stateC_r + stateC_r.prev_state = stateB_r + stateA_r = _rotate_state(stateA_r, rot_angle) + stateB_r = _rotate_state(stateB_r, rot_angle) + stateC_r = _rotate_state(stateC_r, rot_angle) + + # Sanity check for angle between vectors + vec1 = np.array(stateB_r.state_position.get_vec()[:2]) - np.array(stateA_r.state_position.get_vec()[:2]) + vec2 = np.array(stateC_r.state_position.get_vec()[:2]) - np.array(stateB_r.state_position.get_vec()[:2]) + dot_product = np.dot(vec1, vec2) + norm1 = np.linalg.norm(vec1) + norm2 = np.linalg.norm(vec2) + cos_theta = dot_product / (norm1 * norm2) + angle_between = np.degrees(np.arccos(np.clip(cos_theta, -1.0, 1.0))) + if not np.isclose(angle_between, angle, atol=1e-2): + print(f"Angle between vectors: {angle_between:.2f} degrees, should be {angle:.2f} degrees") + # Sanity check end + + result_rot = handler(state_A=stateA_r, state_B=stateB_r).get_junction_vel() + rotations.append(result_rot) + rotated_firmware_results.append((angle, rotations)) + + results[firmware] = firmware_results + rotated_results[firmware] = rotated_firmware_results + + # Convert to DataFrame + df = pd.DataFrame(results) + + fig, ax = plt.subplots(figsize=(6, 4)) + color_map = {} + # Plot solid lines for unrotated + for idx, fw in enumerate(test_firmwares): + line = ax.plot( + angles, + df[fw], + label=fw, + linewidth=2, + )[0] + color_map[fw] = line.get_color() + + # For each firmware, plot all rotated results as curves (one per rotation angle) + for fw in test_firmwares: + # rotated_results[fw] is a list of (angle, [results for each rotation]) + # transpose the data: for each rotation, collect the results for all angles + all_rotations = list(zip(*[rot[1] for rot in rotated_results[fw]])) # shape: (num_rotations, num_angles) + for rot_curve in all_rotations: + ax.plot( + angles, + rot_curve, + color=color_map[fw], + linewidth=1, + alpha=0.075, + ) + + ax.set_ylabel(r"cornering velocity in $\frac{\mathrm{mm}}{\mathrm{s}}$") + ax.set_xlabel(r"turning angle in $\deg$") + ax.set_xticks(range(0, 181, 30)) + ax.set_yticks(range(0, 51, 5)) + ax.grid(visible=True, which="major", axis="both", alpha=0.8, linestyle="dotted") + ax.legend() + + # Ensure output directory exists + output_dir = "tests/output" + os.makedirs(output_dir, exist_ok=True) + fig.savefig(os.path.join(output_dir, "junction_handlings_rotating_COS.png"), dpi=300, bbox_inches="tight") + # plt.show() + + +def test_junction_handling_state_connect(): + """Test for the state connect method in the junction handling.""" + from pyGCodeDecode.gcode_interpreter import setup + + test_setup = setup( + presets_file=pathlib.Path("./tests/data/test_printer_setups.yaml"), + printer="prusa_mini", + layer_cue="LAYER cue", + ) + test_setup.set_property({"p_acc": 10}) + + print(test_setup.firmware) + states = generate_states( + filepath=pathlib.Path("./tests/data/test_state_generator.gcode"), + initial_machine_setup=test_setup.get_dict(), + ) + + blocks = generate_planner_blocks(states, firmware=test_setup.firmware) + + print(blocks) + # TODO assert states are connected correctly + + +if __name__ == "__main__": + test_junction_handlings() + plt.show() + test_junction_handlings_rotating_COS() + plt.show() diff --git a/tests/test_planner_block.py b/tests/test_planner_block.py index d7d98ee..1085c9d 100644 --- a/tests/test_planner_block.py +++ b/tests/test_planner_block.py @@ -32,8 +32,13 @@ def test_planner_block(): # trapezoid block test assert block_1.blocktype == "trapezoid" assert block_1.valid is True - assert block_1.get_segments()[0].get_position(t=0) == pos_0 - assert block_1.get_segments()[-1].get_position(t=block_1.get_segments()[-1].t_end) == pos_1 + assert np.allclose( + block_1.get_segments()[0].get_position(t=0).get_vec(withExtrusion=True), pos_0.get_vec(withExtrusion=True) + ) + assert np.allclose( + block_1.get_segments()[-1].get_position(t=block_1.get_segments()[-1].t_end).get_vec(withExtrusion=True), + pos_1.get_vec(withExtrusion=True), + ) t_end = block_1.get_segments()[-1].t_end assert t_end == (settings.speed * settings.speed + dist * settings.p_acc) / ( @@ -54,8 +59,13 @@ def test_planner_block(): # triangle block test assert block_2.blocktype == "triangle" assert block_2.valid is True - assert block_2.get_segments()[0].get_position(t=0) == pos_0 - assert block_2.get_segments()[-1].get_position(t=block_2.get_segments()[-1].t_end) == pos_1 + assert np.allclose( + block_2.get_segments()[0].get_position(t=0).get_vec(withExtrusion=True), pos_0.get_vec(withExtrusion=True) + ) + assert np.allclose( + block_2.get_segments()[-1].get_position(t=block_2.get_segments()[-1].t_end).get_vec(withExtrusion=True), + pos_1.get_vec(withExtrusion=True), + ) assert np.isclose(block_2.get_segments()[0].t_end, np.sqrt(4 * dist * settings.p_acc) / (2 * settings.p_acc)) assert np.isclose( block_2.get_segments()[0].vel_end.get_norm(), (settings.p_acc * block_2.get_segments()[0].t_end) @@ -73,13 +83,18 @@ def test_planner_block(): state_2 = state(state_position=pos_2, state_p_settings=settings) state_1.prev_state = state_0 state_1.next_state = state_2 # needed next state to create singular PB with non zero ending vel - block_3 = planner_block(state=state_1, prev_block=None, firmware="marlin_jd") + block_3 = planner_block(state=state_1, prev_block=None, firmware="junction_deviation") # single block test assert block_3.blocktype == "single" assert block_3.valid is True - assert block_3.get_segments()[0].get_position(t=0) == pos_0 - assert block_3.get_segments()[-1].get_position(t=block_3.get_segments()[-1].t_end) == pos_1 + assert np.allclose( + block_3.get_segments()[0].get_position(t=0).get_vec(withExtrusion=True), pos_0.get_vec(withExtrusion=True) + ) + assert np.allclose( + block_3.get_segments()[-1].get_position(t=block_3.get_segments()[-1].t_end).get_vec(withExtrusion=True), + pos_1.get_vec(withExtrusion=True), + ) assert np.isclose(block_3.get_segments()[0].t_end, np.sqrt(2 * dist * settings.p_acc) / (settings.p_acc)) assert np.isclose( block_3.get_segments()[0].vel_end.get_norm(), (settings.p_acc * block_3.get_segments()[0].t_end) diff --git a/tests/test_progress_fix.py b/tests/test_progress_fix.py new file mode 100644 index 0000000..4d4b24e --- /dev/null +++ b/tests/test_progress_fix.py @@ -0,0 +1,99 @@ +"""Test for progress bar and custom_print interaction.""" + +import time + +from pyGCodeDecode.helpers import ProgressBar, custom_print, set_verbosity_level + + +def test_progress_bar_with_interruptions(capsys): + """Run the test and capture output.""" + set_verbosity_level(3) + pb1 = ProgressBar("Test Process 1", 20) + for i in range(101): + pb1.update(i / 100.0) + if i == 30: + custom_print("This is a warning message during progress", lvl=1) + elif i == 60: + custom_print("This is an info message during progress", lvl=2) + time.sleep(0.001) + assert pb1.last_progress_update == 100 + + pb2 = ProgressBar("Test Process 2", 15, verbosity_level=1) + for i in range(101): + pb2.update(i / 100.0) + if i == 50: + custom_print("Another message in the middle", lvl=2) + time.sleep(0.001) + assert pb2.last_progress_update == 100 + + custom_print("All tests completed!", lvl=2) + + out, _ = capsys.readouterr() + expected = ( + "[WARN] This is a warning message during progress\n" + "[INFO] This is an info message during progress\n" + "[INFO] βœ… Done with Test Process 1\n" + "[INFO] Another message in the middle\n" + "[WARN] βœ… Done with Test Process 2\n" + "[INFO] All tests completed!\n" + ) + # expected = ( + # "[WARNING] This is a warning message during progress\n" + # "[ INFO ] This is an info message during progress\n" + # "[ INFO ] βœ… Done with Test Process 1\n" + # "[ INFO ] Another message in the middle\n" + # "[WARNING] βœ… Done with Test Process 2\n" + # "[ INFO ] All tests completed!\n" + # ) + + # Normalize whitespace for progress bar lines + def normalize(line): + return line.rstrip() + + out_lines = [normalize(lin) for lin in out.splitlines()] + exp_lines = [normalize(lin) for lin in expected.splitlines()] + # Only keep lines that are expected (messages and final progress bars) + filtered_out_lines = [ + lin for lin in out_lines if lin.startswith("[WARN]") or lin.startswith("[INFO]") or "βœ… Done" in lin + ] + # filtered_out_lines = [ + # lin + # for lin in out_lines + # if lin.startswith("[WARNING]") or lin.startswith("[ INFO ]") or "βœ… Done" in lin + # ] + assert exp_lines == filtered_out_lines + + +def test_progress_bar_no_interruptions(capsys): + """Test progress bar without interruptions.""" + set_verbosity_level(3) + pb = ProgressBar("No Interruptions", 10) + for i in range(101): + pb.update(i / 100.0) + time.sleep(0.001) + assert pb.last_progress_update == 100 + custom_print("Done without interruptions", lvl=2) + out, _ = capsys.readouterr() + assert "[INFO] βœ… Done with No Interruptions" in out + assert "[INFO] Done without interruptions" in out + + +def test_progress_bar_multiple_messages(capsys): + """Test progress bar with multiple messages at different points.""" + set_verbosity_level(3) + pb = ProgressBar("Multiple Messages", 5) + for i in range(101): + pb.update(i / 100.0) + if i == 20: + custom_print("First info", lvl=2) + if i == 40: + custom_print("Second warning", lvl=1) + if i == 80: + custom_print("Third info", lvl=2) + time.sleep(0.001) + assert pb.last_progress_update == 100 + out, _ = capsys.readouterr() + assert "[INFO] First info" in out + assert "[WARN] Second warning" in out + assert "[INFO] Third info" in out + assert "[INFO] βœ… Done with Multiple Messages" in out diff --git a/tests/test_result.py b/tests/test_result.py new file mode 100644 index 0000000..71bf3da --- /dev/null +++ b/tests/test_result.py @@ -0,0 +1,117 @@ +"""Test the result calculation.""" + +import pathlib + +import numpy as np + +from pyGCodeDecode.planner_block import planner_block +from pyGCodeDecode.result import get_all_result_calculators, has_private_results +from pyGCodeDecode.state import state +from pyGCodeDecode.utils import position + + +def test_result_calc_within_pb(): + """Test the result calculation within a planner block.""" + statA = state( + state_position=position(0, 0, 0, 0), + state_p_settings=state.p_settings( + p_acc=1.0, + jerk=1.0, + vX=10.0, + vY=10.0, + vZ=10.0, + vE=10.0, + speed=10.0, + ), + ) + statB = state( + state_position=position(150, 0, 0, 0), + state_p_settings=state.p_settings( + p_acc=1.0, + jerk=1.0, + vX=10.0, + vY=10.0, + vZ=10.0, + vE=10.0, + speed=10.0, + ), + ) + statB.prev_state = statA + pb = planner_block(statB, prev_block=None) + + if has_private_results(): + # Add private results to the planner block + all_calcs = get_all_result_calculators() + + err = all_calcs + print("errs: ", err) + pb.calc_results(*err) + + errs = [calcs.name for calcs in pb.result_calculators] + errs.extend(calculator.name for calculator in err if calculator.name not in errs) + for calculator in err: + if hasattr(calculator, "avgs"): + for avg in calculator.avgs: + errs.append(calculator.name + avg) + + # Check if all results are calculated + for segm in pb.segments: + segm.self_check() + for name in errs: + print(f"Result {name}: {segm.result[name]}") + assert isinstance(segm.result[name], (int, float, list)), f"Error {name} is not a number" + + # Check if the results are calculated correctly + assert len(pb.segments) == 3, "There should be 3 segments in the planner block" + + segm = pb.segments[0] + assert np.isclose(segm.result["acceleration"], 1.0) + assert all(np.isclose(segm.result["velocity"], [0, 10.0])) + assert np.isclose(segm.result["rel_vel_err_tavg"], 0.5) + assert np.isclose(segm.result["rel_vel_err_savg"], 0.333333) + + segm = pb.segments[1] + assert np.isclose(segm.result["rel_vel_err_tavg"], 0.0) + assert np.isclose(segm.result["rel_vel_err_savg"], 0.0) + + segm = pb.segments[2] + assert np.isclose(segm.result["acceleration"], -1.0) + assert all(np.isclose(segm.result["velocity"], [10, 0])) + assert np.isclose(segm.result["rel_vel_err_tavg"], 0.5) + assert np.isclose(segm.result["rel_vel_err_savg"], 0.333333) + + +def test_result_calc_simulation(): + """Testing the result calculation in a simulation.""" + from pyGCodeDecode.gcode_interpreter import setup, simulation + from pyGCodeDecode.result import get_all_result_calculators + + setup_test = setup( + presets_file=pathlib.Path("./tests/data/test_printer_setups.yaml"), + printer="prusa_mini", + ) + + sim = simulation( + gcode_path=pathlib.Path("./tests/data/test_simplest.gcode"), + # machine_name="prusa_mini", + initial_machine_setup=setup_test, + ) + + result_calculators = get_all_result_calculators() + + for calculator in result_calculators: + if hasattr(calculator, "avgs") and isinstance(calculator.avgs, (list, tuple)): + for avg in calculator.avgs: + print(f"Testing existence of average {avg} for {calculator.name}") + assert ( + calculator.name + avg in sim.results + ), f"Result {calculator.name + avg} not found in simulation results" + + if has_private_results(): + print("Private results are available.") + print("Whole simulation results:") + for key, value in sim.results.items(): + print(f" {key}: {value}") + + assert np.isclose(getattr(sim, "rel_vel_err_savg", None), 0.33333, atol=1e-5) + assert np.isclose(getattr(sim, "rel_vel_err_tavg", None), 0.5, atol=1e-5) diff --git a/tests/test_state_generator.py b/tests/test_state_generator.py index 8463be5..d887541 100644 --- a/tests/test_state_generator.py +++ b/tests/test_state_generator.py @@ -71,3 +71,41 @@ def test_state_generator(): assert states[20].state_p_settings.jerk == 5 # jerk settings (M205 X*) assert states[21].pause == 0.5 # dwell (G4) assert states[22].pause == 5 + + +def test_set_initial_position(): + """Test for initial position settings.""" + from pyGCodeDecode.gcode_interpreter import setup + + # setting coords + test_setup = setup( + presets_file=pathlib.Path("./tests/data/test_printer_setups.yaml"), + printer="test", + layer_cue="LAYER cue", + ) + initial_pos = (5, 4, 3, 2) # initial position definition + test_setup.set_initial_position(initial_pos) + + states = generate_states( + filepath=pathlib.Path("./tests/data/test_state_generator.gcode"), + initial_machine_setup=test_setup.get_dict(), + ) + + assert states[0].state_position.get_vec(withExtrusion=True) == list(initial_pos) # test for inital position + + # using first GCode pos + test_setup = setup( + presets_file=pathlib.Path("./tests/data/test_printer_setups.yaml"), + printer="test", + layer_cue="LAYER cue", + ) + initial_pos = (10, 20, 30, 0) # initial position as specified in "./data/test_state_generator.gcode" line 3 + test_setup.set_initial_position("first") + + states = generate_states( + filepath=pathlib.Path("./tests/data/test_state_generator.gcode"), + initial_machine_setup=test_setup.get_dict(), + ) + + # test for inital position at state 2 (line 3) + assert states[2].state_position.get_vec(withExtrusion=True) == list(initial_pos) diff --git a/tests/test_vector4d.py b/tests/test_vector4d.py new file mode 100644 index 0000000..ac2814a --- /dev/null +++ b/tests/test_vector4d.py @@ -0,0 +1,40 @@ +"""Test the result calculation.""" + +import numpy as np + +from pyGCodeDecode.utils import acceleration, position, seconds, velocity + + +def test_4d_vectors(): + """Test the 4d vector functions.""" + pos1 = position(1, 2, 3, 4) + pos2 = position(4, 5, 6, 7) + t = seconds(6) + + dist = pos2 - pos1 + # Check the distance vector + assert np.isclose(dist.x, 3) + assert np.isclose(dist.y, 3) + assert np.isclose(dist.z, 3) + assert np.isclose(dist.e, 3) + + vel = dist / t + # Check the velocity vector + assert isinstance(vel, velocity) + assert np.isclose(vel.x, 0.5) + assert np.isclose(vel.y, 0.5) + assert np.isclose(vel.z, 0.5) + assert np.isclose(vel.e, 0.5) + + # Check the norm of the velocity vector + assert np.isclose(vel.get_norm(), np.sqrt(0.5**2 + 0.5**2 + 0.5**2)) + assert np.isclose(vel.get_norm(withExtrusion=True), np.sqrt(0.5**2 + 0.5**2 + 0.5**2 + 0.5**2)) + + # Check the acceleration vector + acc = vel / seconds(0.5) + + assert isinstance(acc, acceleration) + assert np.isclose(acc.x, 1.0) + assert np.isclose(acc.y, 1.0) + assert np.isclose(acc.z, 1.0) + assert np.isclose(acc.e, 1.0)