Source code for gcode_reader.emulate.additive_part

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