from dataclasses import dataclass, field
from typing import List, Tuple
import logging
from copy import copy
from math import sqrt
from enum import Enum
import numpy as np
import pandas as pd
from ..constants import ZERO_TOLERANCE
from .operations import AdditiveOperation, AdditiveProcessData, ProcessDataArrays
from ._dataframe import insert_layer_indices_gcode_dataframe
[docs]
class BeadShape(Enum):
RECTANGLE = "rectangle"
ROUNDED_RECTANGLE = "rounded_rectangle"
[docs]
@dataclass
class Bead:
"""The cross-sectional properties of an extruded material"""
width: float = 1.0
height: float = 1.0
density: float = 1000.0
shape: BeadShape = BeadShape.ROUNDED_RECTANGLE
[docs]
@classmethod
def from_area(cls, area: float, density: 1000.0, aspect_ratio: float = 4):
width = sqrt(aspect_ratio * area)
height = sqrt(area / aspect_ratio)
return cls(width, height, density)
@property
def area(self):
if self.shape == BeadShape.ROUNDED_RECTANGLE:
return np.pi / 4 * self.height**2 + self.height * (self.width - self.height)
elif self.shape == BeadShape.RECTANGLE:
return self.width * self.height
else:
raise NotImplementedError(
f"area not implemented for BeadShape: {self.shape}"
)
[docs]
@dataclass
class Segment:
"""A linear movement containing extruded material"""
start_location: Tuple[float, float, float]
end_location: Tuple[float, float, float]
bead: Bead
process_data: AdditiveProcessData
id: int = None
def __post_init__(self):
if not isinstance(self.bead, Bead):
raise ValueError("Segment.bead must be an instance of Bead")
if (
not isinstance(self.process_data, AdditiveProcessData)
and self.process_data is not None
):
raise ValueError(
"Segment.process_data must be an instance of AdditiveProcessData"
)
self.start_location = tuple(self.start_location)
self.end_location = tuple(self.end_location)
@property
def length(self):
end = np.asarray(self.end_location, dtype=float)
start = np.asarray(self.start_location, dtype=float)
return np.linalg.norm(end - start)
@property
def volume(self):
# NOTE: Assumes uniform cross-section
return self.bead.area * self.length
@property
def mass(self):
return self.volume * self.bead.density
@property
def centroid(self):
location: np.ndarray = np.mean([self.start_location, self.end_location], axis=0)
return tuple(location)
@property
def deposition_time(self):
return self.process_data.elapsed_time
[docs]
@dataclass
class Filament:
"""Collection of segments forming a continuous extrusion path"""
segments: List[Segment] = field(default_factory=list)
@property
def locations(self):
"""Get all segment locations as an array.
Returns:
np.ndarray: An array of shape (N, 3) containing the start and end locations of each segment.
"""
if len(self.segments) == 0:
return np.empty((0, 3), dtype=float)
points = [segment.start_location for segment in self.segments] + [
self.segments[-1].end_location
]
return np.array(points, dtype=float)
[docs]
def calc_bounds(self):
"""
Calculates the axis-aligned bounding box of all segment locations.
"""
all_locations = self.locations
if all_locations.size == 0:
return None
min = tuple(np.min(all_locations, axis=0))
max = tuple(np.max(all_locations, axis=0))
return (min, max)
@property
def length(self):
"""Calculates the total length of all segments in the filament.
Returns:
float: The total length of all segments.
"""
return sum(segment.length for segment in self.segments)
@property
def volume(self):
"""Calculates the total volume of all segments in the filament.
Returns:
float: The total volume of all segments.
"""
return sum(segment.volume for segment in self.segments)
@property
def mass(self):
"""Calculates the total mass of all segments in the filament.
Returns:
float: The total mass of all segments.
"""
return sum(segment.mass for segment in self.segments)
[docs]
def calc_center_of_gravity(self):
"""Calculates the center of gravity of the filament.
Returns:
np.ndarray: A 3-element array representing the center of gravity.
"""
mass = self.mass
if mass == 0.0 or mass is None:
return np.empty((0, 3), dtype=float)
weighted_segment_centroid = np.sum(
[np.multiply(segment.centroid, segment.mass) for segment in self.segments],
axis=0,
)
return weighted_segment_centroid / mass
@property
def deposition_time(self):
"""Calculates the total deposition time of all segments in the filament.
Returns:
float: The cumulative deposition time for all segments.
"""
return sum(s.deposition_time for s in self.segments)
[docs]
@classmethod
def from_process_data(cls, process_data: List[AdditiveProcessData], bead: Bead):
"""Creates a Filament from a list of process data points.
Args:
process_data (List[AdditiveProcessData]): A list containing sequential process data.
bead (Bead): The bead model to apply to each generated segment.
Returns:
Filament: A new Filament instance.
"""
locations = np.asarray([data.location for data in process_data])
segment_locations = np.stack((locations[:-1], locations[1:]), axis=1)
segments = [
Segment(loc[0], loc[1], copy(bead), process_data[i + 1], id=i)
for i, loc in enumerate(segment_locations)
]
return cls(segments)
[docs]
@classmethod
def from_process_dataframe(cls, df: pd.DataFrame, bead: Bead):
"""Creates a Filament from a DataFrame of process data points.
Args:
df (pd.DataFrame): A DataFrame containing sequential process data,
with columns matching the AdditiveProcessData schema.
bead (Bead): The bead model to apply to each generated segment.
Returns:
Filament: A new Filament instance.
Raises:
TypeError: If input arguments are of the wrong type.
KeyError: If the DataFrame is missing required columns.
"""
if not isinstance(df, (pd.DataFrame)):
raise TypeError(f"df must be type (pandas.DataFrame). Received {type(df)}")
if not isinstance(bead, Bead):
raise TypeError(f"bead must be type Bead. Received {type(bead)}")
# Need to validate columns
process_data_columns = AdditiveProcessData().to_dict().keys()
df_columns = df.columns
if not all(col in df_columns for col in process_data_columns):
raise KeyError(
f"df is missing required columns.\n\tReceived {df_columns}\n\tExpected {process_data_columns}"
)
process_data_reconstructed = [
AdditiveProcessData(**row) for row in df.to_dict("records")
]
return Filament.from_process_data(process_data_reconstructed, bead)
[docs]
@dataclass
class Layer:
filaments: List[Filament] = field(default_factory=list)
plane_location: Tuple[float, float, float] = field(
default_factory=lambda: (0.0, 0.0, 0.0)
)
plane_normal: Tuple[float, float, float] = field(
default_factory=lambda: (0.0, 0.0, 1.0)
)
time: float = 0.0 # cumulative time for this layer
def _points_on_layer(
self, points: np.ndarray, tol: float = ZERO_TOLERANCE
) -> np.ndarray:
"""Checks which points are on the layer within a given tolerance.
Args:
points (np.ndarray): An (N, 3) array of points to check.
tol (float, optional): The tolerance for checking if points are on the layer.
Returns:
np.ndarray: A boolean array of shape (N,) indicating which points are on the layer.
"""
# Check if points satisfy the plane equation within tolerance
# Plane equation from point-normal form:
# https://web.ma.utexas.edu/users/m408m/Display12-5-3.shtml
# Check if: A(x-a) + B(y-b) + ... = 0 (within tolerance)
# Where:
# plane_normal is (A, B, ...)
# plane_location is (a, b, ...)
# filament location is (x, y, ...)
points = np.asarray(points)
plane_location = np.asarray(self.plane_location)
plane_normal = np.asarray(self.plane_normal)
plane_normal = plane_normal / np.linalg.norm(
plane_normal
) # Normalize (may not be necessary if always a unit vector)
abs_distances = np.abs(np.dot(points - plane_location, plane_normal))
return abs_distances < tol
[docs]
def filament_is_on_layer(
self, filament: Filament, tol: float = ZERO_TOLERANCE
) -> bool:
"""Checks if all points in a filament are on the layer within a given tolerance.
Args:
filament (Filament): The filament to check.
tol (float, optional): The tolerance for determining if the filament locations are on the layer.
Returns:
bool: True if all points in the filament are on the layer within the tolerance, False otherwise.
"""
all_locations = (
filament.locations
) # Always returns a (N, 3) array, even if empty
if all_locations.size == 0:
return False
return all(self._points_on_layer(all_locations, tol))
@property
def filament_locations(self) -> np.ndarray:
"""Concatenates all filament locations into a single NumPy array.
Returns:
np.ndarray: An (N, 3) array of all points. If there are no
points, returns an empty array with shape (0, 3)
"""
all_locations = [f.locations for f in self.filaments if f.locations.size > 0]
if not all_locations:
return np.empty((0, 3), dtype=float)
return np.concatenate(all_locations)
[docs]
def calc_bounds(self):
"""Calculates the axis-aligned bounding box of all filament locations.
Returns:
Tuple: A nested tuple of ((min_x, min_y, min_z), (max_x, max_y, max_z)). If there are no locations, returns None.
"""
all_locations = (
self.filament_locations
) # Always returns a (N, 3) array, even if empty
if all_locations.size == 0:
return None
min = tuple(np.min(all_locations, axis=0))
max = tuple(np.max(all_locations, axis=0))
return (min, max)
@property
def length(self):
"""Calculates the cumulative length of all filaments in the layer."""
return sum(filament.length for filament in self.filaments)
@property
def volume(self):
"""Calculates the cumulative volume of all filaments in the layer."""
return sum(filament.volume for filament in self.filaments)
@property
def mass(self):
"""Calculates the cumulative mass of all filaments in the layer."""
return sum(filament.mass for filament in self.filaments)
[docs]
def calc_center_of_gravity(self):
"""Calculates the center of gravity of the layer.
Returns:
np.ndarray: A 3-element array representing the center of gravity.
"""
mass = self.mass
if mass == 0.0 or mass is None:
return np.empty((0, 3), dtype=float)
weighted_centers = np.sum(
[
np.multiply(segment.centroid, segment.mass)
for filament in self.filaments
for segment in filament.segments
],
axis=0,
)
return weighted_centers / mass
@property
def deposition_time(self):
"""Calculates the cumulative deposition time of all filaments in the layer."""
return sum(f.deposition_time for f in self.filaments)
[docs]
@dataclass
class AdditivePart:
"""Represents an additively manufactured part, the result of an operation.
Attributes:
operation: The source operation whose process data describes the toolpath.
dataframe: A per-deposition-segment summary DataFrame. Each row represents
one continuous deposition or travel move, with columns for elapsed time,
distance, start/end process-data indices, and layer annotations. Required
columns are validated on construction.
bead: The cross-sectional bead model used for volume, mass, and inertia
calculations.
layer_normal: A unit vector (x, y, z) normal to the build plane, used to
partition process data into discrete layers.
"""
operation: AdditiveOperation
dataframe: pd.DataFrame
bead: Bead
layer_normal: Tuple[float, float, float]
_layer_df: pd.DataFrame = field(default=None, repr=False, compare=False)
def __post_init__(self):
if self.operation is not None and not isinstance(
self.operation, AdditiveOperation
):
raise TypeError(f"operation must be an instance of AdditiveOperation")
if self.dataframe is not None and not isinstance(self.dataframe, pd.DataFrame):
raise TypeError(f"dataframe must be an instance of pandas.DataFrame")
if self.bead is not None and not isinstance(self.bead, Bead):
raise TypeError(f"bead must be an instance of Bead")
self._validate_dataframe_columns()
def _validate_dataframe_columns(self):
"""Ensures the internal dataframe contains all required columns.
Raises:
ValueError: If `self.dataframe` is present but missing one or more
required columns.
"""
required_cols = [
"elapsed_time",
"distance",
"is_depositing",
"start",
"end",
"first_location",
]
if self.dataframe is None:
return
columns = self.dataframe.columns.to_list()
if not all([col in columns for col in required_cols]):
raise ValueError(
f"self.dataframe missing one of required columns: {required_cols}. Received:{columns}"
)
def _validate_layer_range(self, layer_range: tuple = None):
"""Validates the given layer range tuple and provides a default.
If the range is None, it defaults to (0, n_layers).
Args:
layer_range (tuple, optional): A (min, max) tuple of layer indices.
Returns:
tuple: A validated (min, max) tuple of layer indices.
Raises:
TypeError: If `layer_range` is not a tuple or list.
ValueError: If `layer_range` does not have exactly two elements.
IndexError: If the upper bound of `layer_range` exceeds `self.n_layers`.
"""
if layer_range is not None:
if not isinstance(layer_range, (tuple, list)):
raise TypeError(
f"layer_range must be an iterable (tuple or list) of length 2. I.e. (0,2)"
)
if len(layer_range) != 2:
raise ValueError(
f"layer_range must be iterable of length 2. I.e. (0,2)"
)
if layer_range[1] > self.n_layers:
raise IndexError(
f"layer_range upper bound {layer_range[1]} > {self.n_layers} number of layers"
)
return layer_range
return (0, self.n_layers)
@property # NOTE: Potential candidate for caching
def deposition_dataframe(self):
"""Filters the part dataframe to rows where material is being deposited.
Returns:
pd.DataFrame: A subset of ``self.dataframe`` where ``is_depositing``
is True, or None if ``self.dataframe`` is None.
"""
return (
self.dataframe[self.dataframe["is_depositing"]]
if self.dataframe is not None
else None
)
@property
def n_layers(self): # NOTE: Potential candidate for caching
"""Calculates the total number of layers."""
if self._layer_df is None:
return 0
return self._layer_df[self._layer_df["is_depositing"]]["layer_index"].nunique()
@property
def deposition_length(self):
"""Calculates the total length of all deposited material."""
deposition_df = self.deposition_dataframe
return deposition_df["distance"].sum() if deposition_df is not None else 0.0
@property
def volume(self):
"""Calculates the total volume of deposited material."""
deposition_length = self.deposition_length
return deposition_length * self.bead.area if self.bead is not None else 0.0
@property
def mass(self):
"""Calculates the total mass of deposited material."""
return self.volume * self.bead.density if self.bead is not None else 0.0
[docs]
def generate_layers(self, layer_range: tuple = None):
"""Generates Layer objects for the part, one by one.
Args:
layer_range (tuple, optional): A (min, max) tuple of layer indices
to filter which layers are generated. Defaults to all layers
if None.
Yields:
Layer: A `Layer` object containing all filaments for a given layer.
"""
layer_range = self._validate_layer_range(layer_range)
for i in range(layer_range[0], layer_range[1]):
layer_df = self._layer_df
in_layer = layer_df["layer_index"] == i
layer_time = layer_df[in_layer]["elapsed_time"].sum()
layer_height = layer_df[in_layer]["layer_height"].iloc[0]
deposition_in_layer = layer_df[in_layer & layer_df["is_depositing"]]
filaments = []
for _, deposition_group in deposition_in_layer.groupby("deposition_id"):
start = max(0, int(deposition_group["cmd_index"].iloc[0]) - 1)
end = int(deposition_group["cmd_index"].iloc[-1])
filaments.append(
Filament.from_process_data(
self.operation.process_data[start : end + 1], self.bead
)
)
yield Layer(
filaments=filaments,
plane_location=layer_height,
plane_normal=self.layer_normal,
time=layer_time,
)
[docs]
def generate_deposition_paths(self):
"""Generates deposition paths (filaments) as lists of coordinates.
Yields:
list: A list of coordinate tuples `[(x, y, z), ...]` representing
a single, continuous deposition path.
"""
deposition_df = self.deposition_dataframe
for deposition in deposition_df.itertuples():
pd_slice = self.operation.process_data[
deposition.start : deposition.end + 1
] # +1 required since range is not inclusive
yield [data.location for data in pd_slice]
[docs]
def calc_deposition_bounds(self):
"""Calculates the axis-aligned bounding box of all deposition locations in all layers.
Returns:
Tuple: A nested tuple of ((min_x, min_y, min_z), (max_x, max_y, max_z)). If there are no locations, returns None.
"""
if self.dataframe is None:
return None
all_locations_unstacked = list(self.generate_deposition_paths())
all_locations = np.vstack(all_locations_unstacked)
if all_locations.size == 0:
return None
min = np.min(all_locations, axis=0)
max = np.max(all_locations, axis=0)
return (tuple(min), tuple(max))
def _calc_segment_centroids_masses(self, use_process_data=True):
"""Calculates the centroid and mass for every deposition segment.
A "segment" is the line between two consecutive points in the process data
during deposition. This is a helper method for physics calculations.
Args:
use_process_data (bool): If True, derives segment mass from the
``deposited_volume`` stored in each ``AdditiveProcessData`` point.
If False, falls back to ``bead.area * segment_length * bead.density``,
which assumes a constant cross-section regardless of process conditions.
Returns:
np.ndarray: An (N, 4) array where each row is [cx, cy, cz, mass]
for a segment. Returns an empty array if no deposition.
"""
# Vectorized and chunked implementation to support very large datasets.
# Returns an (N,4) array with columns [cx, cy, cz, mass]
if self.dataframe is None:
return np.empty((0, 4), dtype=float)
deposition_df = self.deposition_dataframe
arrays = self.operation.arrays
all_segments = []
all_deposited_volumes = []
for deposition in deposition_df.itertuples():
start = deposition.start
end = deposition.end + 1 # +1 required since range is not inclusive
locations = arrays.location[start:end]
# None deposited_volume becomes NaN in the columnar build; map back to 0.0
volumes = np.nan_to_num(arrays.deposited_volume[start:end], nan=0.0)
all_segments.append(np.stack((locations[:-1], locations[1:]), axis=1))
all_deposited_volumes.append(
volumes[1:]
) # Exclude the first volume, because it is associated with the movement to reach the starting location for this deposition
if not all_segments:
return np.empty((0, 4), dtype=float)
all_segments = np.vstack(all_segments)
deposited_volumes = np.concatenate(all_deposited_volumes)
centroids = all_segments.mean(axis=1)
masses = deposited_volumes * self.bead.density
# NOTE: We use deposited volume from AdditiveProcessData per deposition segment. This falls back to bead area * segment length if use_process_data == False
# This could potentially be re-written so that the calculation in the loop wasn't wasted
if not use_process_data:
logging.info(f"Mass based on user specified constant bead")
vectors = np.diff(all_segments, axis=1).squeeze(axis=1)
lengths = np.linalg.norm(vectors, axis=1)
masses = self.bead.area * self.bead.density * lengths
return np.hstack([centroids, masses.reshape(-1, 1)])
[docs]
def calc_center_of_gravity(self, centroids_masses=None, use_process_data=True):
"""Calculates the center of gravity of the part.
Args:
centroids_masses (np.ndarray, optional): An (N, 4) array of
[cx, cy, cz, mass] rows as returned by
``_calc_segment_centroids_masses()``. Pass a pre-computed value
to avoid redundant computation when calling multiple physics
methods. If None, the array is computed internally.
use_process_data (bool): Forwarded to ``_calc_segment_centroids_masses()``
when ``centroids_masses`` is None. See that method for details.
Returns:
np.ndarray: A 3-element array [x, y, z] representing the center of
gravity, or an empty (0, 3) array if the part has no mass.
"""
# NOTE: Passing the centroids_masses greatly improves performance
if centroids_masses is None or (
isinstance(centroids_masses, list) and len(centroids_masses) == 0
):
centroids_masses = self._calc_segment_centroids_masses(use_process_data)
if centroids_masses.size == 0:
return np.empty((0, 3), dtype=float)
centroids = centroids_masses[:, :3] # x, y, z
masses = centroids_masses[:, 3] # mass
mass = np.sum(masses)
if mass == 0.0: # np.sum will never return None
return np.empty((0, 3), dtype=float)
weighted_centers = np.multiply(centroids.T, masses).T
return np.sum(weighted_centers, axis=0) / mass
[docs]
def calc_moment_of_inertia(self, centroids_masses=None, use_process_data=True):
"""Calculates the moment of inertia of the part about its center of gravity.
Uses a point-mass approximation: each deposition segment is treated as a
point mass located at its centroid. Rotational inertia of individual
segments about their own axes is not included.
Args:
centroids_masses (np.ndarray, optional): An (N, 4) array of
[cx, cy, cz, mass] rows as returned by
``_calc_segment_centroids_masses()``. Pass a pre-computed value
to avoid redundant computation when calling multiple physics
methods. If None, the array is computed internally.
use_process_data (bool): Forwarded to ``_calc_segment_centroids_masses()``
when ``centroids_masses`` is None. See that method for details.
Returns:
np.ndarray: A 3x3 inertia tensor. Returns a zero matrix if the part
has no mass.
"""
if centroids_masses is None or (
isinstance(centroids_masses, list) and len(centroids_masses) == 0
):
centroids_masses = self._calc_segment_centroids_masses(use_process_data)
if centroids_masses.size == 0:
return np.zeros((3, 3))
cg = self.calc_center_of_gravity(centroids_masses)
weighted_centroids = centroids_masses[:, :3] - cg # x, y, z
masses = centroids_masses[:, 3] # mass
x, y, z = weighted_centroids.T[0:3]
x2 = x**2
y2 = y**2
z2 = z**2
Ixx = np.sum(masses * (y2 + z2))
Iyy = np.sum(masses * (x2 + z2))
Izz = np.sum(masses * (x2 + y2))
Ixy = -np.sum(masses * x * y)
Iyz = -np.sum(masses * y * z)
Ixz = -np.sum(masses * x * z)
return (
np.array([[Ixx, Ixy, Ixz], [Ixy, Iyy, Iyz], [Ixz, Iyz, Izz]]) + 0.0
) # Normalizes -0.0 to 0.
# layer times
@property
def layer_times(self) -> dict:
"""Gets the total elapsed time per layer (deposition and travel)."""
layer_time_df = self.dataframe.groupby("layer_index").agg(
elapsed_time=("elapsed_time", "sum")
)
return layer_time_df["elapsed_time"].to_dict()
@property
def deposition_times(self) -> dict:
"""Gets the total elapsed time per deposition movement."""
return self.deposition_dataframe["elapsed_time"].to_dict()
[docs]
@classmethod
def from_process_data(
cls,
process_data: List[AdditiveProcessData],
bead: Bead,
layer_normal: Tuple[float, float, float] = None,
):
"""Creates an AdditivePart from a list of granular process data.
Args:
process_data (List[AdditiveProcessData]): A list of data objects,
where each represents a discrete step in the process.
bead (Bead): The geometric bead model to apply to all
extruded filaments.
layer_normal (Tuple[float, float, float]): The constant plane normal direction to identify layers.
Returns:
AdditivePart: A new instance containing the structured layers.
Raises:
TypeError: If process_data is not a list-like object.
ValueError: If bead is not a valid Bead instance.
"""
# Input handling
if not isinstance(process_data, (list, tuple, np.ndarray, ProcessDataArrays)):
raise TypeError(
f"AdditivePart.from_process_data: process_data arg must be iterable (list, tuple, np.ndarray, ProcessDataArrays). Received type {type(process_data)}"
)
elif len(process_data) == 0:
logging.warning(
f"AdditivePart.from_process_data: process_data has zero length"
)
return cls(operation=None, dataframe=None, bead=None, layer_normal=None)
elif not isinstance(bead, Bead):
raise ValueError(
f"AdditivePart.from_process_data: bead arg must be an instance of Bead. Received {type(bead)}."
)
if layer_normal is not None:
if not isinstance(layer_normal, (list, tuple)):
raise TypeError(
f"AdditivePart.from_process_data: layer_normal arg must be iterable (list, tuple). Received type {type(layer_normal)}"
)
elif len(layer_normal) != 3:
raise ValueError(
f"AdditivePart.from_process_data: layer_normal arg must be iterable (list, tuple) of length 3."
)
layer_normal = np.asarray(layer_normal)
norm = np.linalg.norm(layer_normal)
if norm < ZERO_TOLERANCE:
raise ValueError(
f"AdditivePart.from_process_data: layer_normal arg must have a magnitude. Received {layer_normal} which has magnitude {norm}"
)
layer_normal = layer_normal / norm
logging.info(
f"AdditivePart.from_process_data: Using user-specified layer_normal = {layer_normal}"
)
if isinstance(process_data, ProcessDataArrays):
arrays = process_data
else:
arrays = ProcessDataArrays.from_process_data_list(process_data)
df = pd.DataFrame(
{
"elapsed_time": arrays.elapsed_time,
"location": list(map(tuple, arrays.location)),
"tool_direction": list(map(tuple, arrays.tool_direction)),
"relative_movement": list(map(tuple, arrays.relative_movement)),
"distance": arrays.distance,
"feed_rate": arrays.feed_rate,
"deposited_volume": arrays.deposited_volume,
"bead_area": arrays.bead_area,
"extruder_type": arrays.extruder_type,
}
)
# Attempt to pull the layer_normal out of process data. Fall back to a default normal direction if process_data is not planar
if layer_normal is None:
df_is_planar = df["tool_direction"].nunique()
if df_is_planar != 1:
logging.warning(
"AdditivePart.from_process_data: Process data could not be determined to be planar. Falling back to layer normal = (0,0,1)."
)
layer_normal = (0, 0, 1)
else:
layer_normal = df.at[
df["tool_direction"].first_valid_index(), "tool_direction"
]
# Identify all deposition (extrusion) movements by filtering the dataframe into chunks
# based on the deposited_volume column
# This assumes that deposited_volume is always > 0 during extrusion, and = 0 during travel
# The chunks will discard travel movements
df = df.reset_index() # adds index column
df["is_depositing"] = df["deposited_volume"] > 0
df["deposition_id"] = (
df["is_depositing"].shift(1) != df["is_depositing"]
).cumsum()
# Layer information is retained in its own dataframe
df = insert_layer_indices_gcode_dataframe(
df, layer_normal=layer_normal, location_col="location"
)
_layer_df = (
df[
[
"index",
"is_depositing",
"layer_index",
"layer_height",
"deposition_id",
"elapsed_time",
]
]
.rename(columns={"index": "cmd_index"})
.copy()
)
# The primary dataframe is organized around deposition
part_df = (
df.groupby("deposition_id")
.agg(
elapsed_time=("elapsed_time", "sum"),
distance=("distance", "sum"),
is_depositing=("is_depositing", "first"),
start=("index", "first"),
end=("index", "last"),
first_location=("location", "first"),
layer_height=("layer_height", "first"), # annotation
layer_index=("layer_index", "first"), # annotation
)
.reset_index()
)
# This will capture the start location of the deposition
is_depositing_mask = part_df["is_depositing"]
part_df.loc[is_depositing_mask, "start"] = (
part_df.loc[is_depositing_mask, "start"] - 1
).clip(lower=0)
pd_list = [] if isinstance(process_data, ProcessDataArrays) else process_data
return cls(
AdditiveOperation(process_data=pd_list),
part_df,
bead,
layer_normal,
_layer_df,
)
[docs]
@classmethod
def from_operation(
cls,
operation: AdditiveOperation,
bead: Bead,
layer_normal: Tuple[float, float, float] = None,
):
"""Creates an AdditivePart from an operation.
Args:
operation (AdditiveOperation): An emulated operation containing process data.
bead (Bead): The geometric bead model to apply to all
extruded filaments.
layer_normal (Tuple): The constant plane normal direction to identify layers.
Returns:
AdditivePart: A new instance containing the structured layers.
Raises:
Warning: If ``operation`` has not been processed by a Machine.
"""
if operation.processed_by_machine is None:
logging.warning(
f"AdditivePart.from_operation: Operation ({operation.name}) has not been processed by a Machine"
)
src = operation.arrays if operation.process_data else operation.process_data
part = AdditivePart.from_process_data(src, bead, layer_normal)
part.operation = operation
return part
[docs]
def get_statistics(self):
"""Compiles a dictionary of statistics describing the part.
Raises:
ValueError: If self.operation is not an AdditiveOperation.
Returns:
dict: A dictionary containing the part's statistics.
"""
if not isinstance(self.operation, AdditiveOperation):
raise ValueError(
"AdditivePart.get_statistics: operation must be an AdditiveOperation to get statistics."
)
stats = self.operation.get_statistics()
stats["n_layers"] = self.n_layers
stats["layer_times"] = self.layer_times
stats["deposition_times"] = self.deposition_times
stats["deposition_bounds"] = self.calc_deposition_bounds()
stats["deposition_length"] = self.deposition_length
stats["deposition_volume"] = self.volume
stats["mass"] = self.mass
centroids_masses = self._calc_segment_centroids_masses()
stats["center_of_gravity"] = self.calc_center_of_gravity(centroids_masses)
stats["moment_of_inertia"] = self.calc_moment_of_inertia(centroids_masses)
return stats