Source code for gcode_reader.emulate.extruders

import logging

import pandas as pd

from ..constants import ZERO_TOLERANCE
from .operations import AdditiveProcessData
from .tools import Tool
from .machine_options import MachineOptions, REL_EXTRUSION, ABS_EXTRUSION
from . import commands


[docs] class Extruder(Tool): """A generic base class for an additive manufacturing extruder. This class provides the core structure for tracking extrusion state, such as the last deposited volume and extrusion rate. It is not meant to be used directly but should be subclassed to implement a specific physical model for a given extrusion technology. Attributes: last_deposited_volume (float): The volume of material deposited by the most recent `commands.Extrude` command. last_extrusion_rate (float): The control value for the extrusion rate (e.g., RPM, mm/s, volumetric flow) set by the most recent command. """ def __init__(self, options: MachineOptions = None): """Initializes a new Extruder instance. Args: options (MachineOptions): Configuration settings for the machine and this tool. """ super().__init__(options=options) self.last_deposited_volume: float = 0.0 # Most recent deposition volume output self.last_extrusion_rate: float = 0.0 # Most recent extrusion rate control self.last_bead_area: float = None # Most recent bead area control/output # Default is absolute extrusion. We defer to the options object on init, then defer to extruder state after init self._extrusion_type = ABS_EXTRUSION self.extrusion_type = self.options.extrusion_type
[docs] def reset_history(self): super().reset_history() self.last_deposited_volume = 0.0 self.last_extrusion_rate: float = 0.0 self.last_bead_area = None
@Tool.options.setter def options(self, value): self._options = value @property def extrusion_type(self): return self._extrusion_type @extrusion_type.setter def extrusion_type(self, val): if not isinstance(val, str): raise TypeError( f"Invalid extrusion_type: '{type(val).__name__}'. Must be str." ) val_lower = val.lower() if val_lower == REL_EXTRUSION.lower(): clean_value = REL_EXTRUSION elif val_lower == ABS_EXTRUSION.lower(): clean_value = ABS_EXTRUSION else: raise ValueError( f"Invalid extrusion_type: '{val}'. " f"Must be '{REL_EXTRUSION}' or '{ABS_EXTRUSION}'." ) self._extrusion_type = clean_value @property def relative_extrusion(self): return self._extrusion_type == REL_EXTRUSION @relative_extrusion.setter def relative_extrusion(self, val): if not isinstance(val, bool): raise TypeError( f"Invalid relative_extrusion type: '{type(val).__name__}'. Type must be bool" ) self._extrusion_type = REL_EXTRUSION if val else ABS_EXTRUSION
[docs] def process_command( self, command: commands.Command, process_data: AdditiveProcessData ): """Processes a command to update the extruder's state. If the command is an `commands.Extrude` command, this method dispatches to the appropriate `_calc_*` method to update the tool's state based on the extrusion model implemented by the subclass. Args: command (Command): The command to process. process_data (AdditiveProcessData): The data object to populate with post-processing information. Returns: The updated AdditiveProcessData object. """ if isinstance(command, commands.Extrude): # We have two control options from the command: # deposited_volume: Controls the amount of material directly, the required tool setting (extrusion_rate) is calculated # exrusion_rate_code: Controls the tool extrusion setting directly, the deposited volume is calculated. if pd.notna(command.deposited_volume): self._calc_last_extrusion_rate(command, process_data) elif pd.notna(command.extrusion_rate): self._calc_last_deposited_volume(command, process_data) else: logging.warning( "Extruder: Processing extrude command without extrusion data" ) process_data = super().process_command(command, process_data) process_data = self._set_process_data(process_data) return process_data
def _calc_last_extrusion_rate( self, command: commands.Extrude, process_data: AdditiveProcessData ): """(Abstract) Calculates the extrusion rate from a deposited volume. This method must be implemented by a subclass to define the physical relationship between a desired volume and the required machine control value. """ raise NotImplementedError( "Subclasses must implement _calc_last_extrusion_rate." ) def _calc_last_deposited_volume( self, command: commands.Extrude, process_data: AdditiveProcessData ): """(Abstract) Calculates the deposited volume from an extrusion rate. This method must be implemented by a subclass to define the physical relationship between a machine control value and the resulting deposited volume. """ raise NotImplementedError( "Subclasses must implement _calc_last_deposited_volume." ) def _set_process_data(self, process_data: AdditiveProcessData): """Populates the process_data object with the final state of the extruder.""" if not isinstance(process_data, AdditiveProcessData): return process_data process_data.deposited_volume = ( self.last_deposited_volume ) # NOTE: If multiple tools are extruding simultaneously, this will not work! process_data.extruder_type = type(self) process_data.bead_area = self.last_bead_area return process_data
[docs] class ScrewModel: """(Abstract) Defines the physical calculation interface for a screw extruder. This class acts as a strategy for the `ScrewExtruder`, separating the physical model (how RPM relates to volume) from the extruder's state-holding logic. Attributes: nozzle_diameter (float): The diameter of the extruder nozzle (in length units). displacement (float): The volume of material extruded per full revolution of the screw (e.g., in units³/revolution). flow_rate_time_base_s (float): The conversion factor (in seconds) for the extrusion rate. Defaults to 60.0 to convert RPM (units/minute) to the emulator's standard units/second. """ def __init__(self, nozzle_diameter=0, displacement=1.0): """Initializes the screw model's physical parameters. Args: nozzle_diameter (float): The diameter of the extruder nozzle (in length units). displacement (float): The volume of material extruded per full revolution of the screw (e.g., in units³/revolution). """ self.nozzle_diameter = float(nozzle_diameter) self.displacement = float(displacement) self.flow_rate_time_base_s = 60.0
[docs] def calc_deposited_volume( self, extruder, command: commands.Command, process_data: AdditiveProcessData ): """Calculates deposited volume from screw RPM and elapsed time. Returns: (float, float): A tuple of (deposited_volume, extrusion_rate). """ raise NotImplementedError()
[docs] def calc_extrusion_rate( self, extruder, command: commands.Extrude, process_data: AdditiveProcessData ): """Calculates the required screw RPM to achieve a target deposited volume. Returns: (float, float): A tuple of (extrusion_rate, deposited_volume). """ raise NotImplementedError()
[docs] class LinearScrewModel(ScrewModel): """A linear model for a screw extruder. This model assumes a direct, linear relationship between the screw's rotational speed (RPM) and the volumetric flow rate: `Flow Rate (units³/min) = RPM * displacement`. """ def __init__(self, nozzle_diameter=0, displacement=1.0): super().__init__(nozzle_diameter, displacement)
[docs] def calc_deposited_volume( self, extruder, command: commands.Command, process_data: AdditiveProcessData ): """Calculates deposited volume based on RPM and time. Uses the formula: `Volume = (RPM * displacement / self.flow_rate_time_base_s) * elapsed_time` Returns: (float, float): A tuple of (deposited_volume, extrusion_rate). """ # For a screw extruder, the extrusion rate code isn't always supplied for every command # Sometimes it's set via a configuration command extrusion_rate = extruder.last_extrusion_rate if isinstance(command, commands.Extrude) and command.extrusion_rate is not None: extrusion_rate = command.extrusion_rate if extrusion_rate is None or self.displacement is None: return (0.0, extrusion_rate) flow_rate = ( extrusion_rate * self.displacement ) / self.flow_rate_time_base_s # unit^3/s if ( process_data.elapsed_time == 0.0 and process_data.distance is not None and process_data.distance > 0 ): return (0.0, extrusion_rate) return (flow_rate * process_data.elapsed_time, extrusion_rate)
[docs] def calc_extrusion_rate( self, extruder, command: commands.Extrude, process_data: AdditiveProcessData ): """Calculates the required screw RPM to achieve a target deposited volume. Uses the formula: `RPM = (Volume / elapsed_time * self.flow_rate_time_base_s) / displacement` Returns: (float, float): A tuple of (extrusion_rate, deposited_volume). """ deposited_volume = extruder.last_deposited_volume if command.deposited_volume is not None: deposited_volume = command.deposited_volume if deposited_volume is None: return (0.0, deposited_volume) # To avoid division by zero, return and don't update the tool's extrusion rate if ( abs(process_data.elapsed_time) < ZERO_TOLERANCE or abs(self.displacement) < ZERO_TOLERANCE ): return (0.0, deposited_volume) flow_rate = ( deposited_volume / process_data.elapsed_time ) * 60.0 # convert to RPM return (flow_rate / self.displacement, deposited_volume)
[docs] class MeltPumpModel(ScrewModel): """A special model where the control value directly specifies deposition volume. This model assumes the `extrusion_rate` in a command (e.g., 'E100') is a direct specification of the *cumulative* volume deposited so far (e.g., 100 mm³). The volume deposited for a given move is the delta between the current E value and the previous E value: ``deposited_volume = max(E_current - E_last, 0)``. Time and displacement are ignored — rate == volume. ``calc_deposited_volume`` is the method called for both ``MoveExtrude`` commands (explicit E) and plain ``Move`` commands (no E, i.e. implicit extruder off). """ def __init__(self, nozzle_diameter=0, displacement=1.0): super().__init__(nozzle_diameter, displacement)
[docs] def calc_deposited_volume( self, extruder, command: commands.Command, process_data: AdditiveProcessData ): """Calculates the deposited volume based on the command's extrusion rate. For the MeltPump, the command's extrusion rate is taken directly as the deposited volume, as they are equivalent. Returns: (float, float): A tuple of (deposited_volume, extrusion_rate). """ last_rate = ( extruder.last_extrusion_rate if extruder.last_extrusion_rate is not None else 0.0 ) extrusion_rate = last_rate if isinstance(command, commands.Extrude) and command.extrusion_rate is not None: extrusion_rate = command.extrusion_rate return (max(extrusion_rate - last_rate, 0.0), extrusion_rate)
[docs] def calc_extrusion_rate( self, extruder, command: commands.Extrude, process_data: AdditiveProcessData ): """Calculates the required extrusion rate based on the command's deposited volume. For the MeltPumpExtruder, the command's volume is taken directly as the required tool extrusion rate, as they are equivalent. Returns: (float, float): A tuple of (extrusion_rate, deposited_volume). """ deposited_volume = extruder.last_deposited_volume if ( isinstance(command, commands.Extrude) and command.deposited_volume is not None ): deposited_volume = command.deposited_volume return (deposited_volume, deposited_volume)
[docs] class ScrewExtruder(Extruder): """An extruder controlled by screw RPM, which processes pellets into a bead. The `extrusion_rate` control value is interpreted as the screw's rotational speed (e.g., in RPM). The deposited volume is calculated based on this speed, the tool's displacement factor, and the command's duration. Attributes: nozzle_diameter (float): The diameter of the extruder nozzle. displacement (float): A factor representing the volume of material extruded per full revolution of the screw (e.g., in mm^3/revolution). """ def __init__(self, options: MachineOptions = None, model: ScrewModel = None): super().__init__(options) # It is always relative extrusion in that the command extrusion rate always specifies the screw RPM self.options.extrusion_type = REL_EXTRUSION if model is None: model = LinearScrewModel() elif not isinstance(model, ScrewModel): raise TypeError( f"ScrewExtruder requires a ScrewModel. Received instance of {type(model).__name__} as model." ) self.model = model
[docs] def process_command( self, command: commands.Command, process_data: AdditiveProcessData ): # Call super() first so that we can grab the tool orientation logic from the base Tool process_data = super().process_command(command, process_data) if not isinstance(command, commands.Extrude) and isinstance( command, commands.Move ): self._calc_last_deposited_volume(command, process_data) process_data = self._set_process_data(process_data) return process_data
def _calc_last_deposited_volume( self, command: commands.Command, process_data: AdditiveProcessData ): """Calculates deposited volume from screw RPM and elapsed time.""" deposited_volume, extrusion_rate = self.model.calc_deposited_volume( self, command, process_data ) self.last_deposited_volume = deposited_volume self.last_extrusion_rate = extrusion_rate # Model relating volume to RPM def _calc_last_extrusion_rate( self, command: commands.Extrude, process_data: AdditiveProcessData ): """Calculates the required screw RPM to achieve a target deposited volume.""" extrusion_rate, deposited_volume = self.model.calc_extrusion_rate( self, command, process_data ) self.last_extrusion_rate = extrusion_rate self.last_deposited_volume = deposited_volume
[docs] class MeltPumpExtruder(ScrewExtruder): """A special ScrewExtruder where the control value directly specifies deposition volume. This model assumes the `extrusion_rate` in a command is a direct specification of the volume to be deposited for that command's duration. It operates in a relative mode. """ def __init__(self, options: MachineOptions = None, model: ScrewModel = None): # this will set the model to LinearScrew by default if model = None. We overwrite that below super().__init__(options, model) if model is None: model = MeltPumpModel() elif not isinstance(MeltPumpModel): raise TypeError( f"MeltPumpExtruder requires a MeltPumpModel. Received instance of {type(model).__name__} as model." ) self.model = model
[docs] class StepperExtruder(Extruder): """An extruder controlled by filament length, common in FDM/FFF printers. This model processes a continuous filament. The `extrusion_rate` control value represents the length of filament to feed (in relative mode) or the absolute position of the extruder motor (in absolute mode). Attributes: filament_area (float): The cross-sectional area of the filament, calculated from its diameter. """ def __init__( self, options: MachineOptions = None, filament_area=1.0, extrusion_multiplier=1.0, ): super().__init__(options) self.filament_area = filament_area if self.filament_area < ZERO_TOLERANCE: logging.warning("StepperExtruder tool: filament_area is 0.") self.extrusion_multiplier = extrusion_multiplier # Override extrusion type on init. StepperExtruders are typically in relative mode. self.relative_extrusion = True # Extrudes by specified length # Volume is unit of extrusion multiplied by area of filament def _calc_last_extrusion_rate( self, command: commands.Extrude, process_data: AdditiveProcessData ): """Calculates the filament length required to deposit a given volume.""" if self._extrusion_type == ABS_EXTRUSION: # new_e = (deposit / area) + old_e e = command.deposited_volume / self.filament_area e = e / self.extrusion_multiplier self.last_extrusion_rate += e else: # e = deposit / area e = command.deposited_volume / self.filament_area e = e / self.extrusion_multiplier self.last_extrusion_rate = e self.last_deposited_volume = command.deposited_volume def _calc_last_deposited_volume( self, command: commands.Extrude, process_data: AdditiveProcessData ): """Calculates the deposited volume from a given filament length.""" if self.relative_extrusion: # deposit = (new_e - old_e) * area * multiplier self.last_deposited_volume = ( (command.extrusion_rate - self.last_extrusion_rate) * self.filament_area * self.extrusion_multiplier ) else: # deposit = e * area * multiplier self.last_deposited_volume = ( command.extrusion_rate * self.filament_area * self.extrusion_multiplier ) self.last_extrusion_rate = command.extrusion_rate