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