""" Scan Model Class. Holds the scan configuration, and computes the required start and end points for various specified angles. Python implementation of SC3ScanModel.cs """ import math from decimal import Decimal, InvalidOperation from typing import List, Optional import csv class SC3ScanModel: """ Scan Model for generating scan paths and rotated scans. Manages scan configuration and computes scan coordinates at various angles. """ def __init__(self): # Private variables self._x_origin: Decimal = Decimal('0.0') self._y_origin: Decimal = Decimal('0.0') self._x_delta: Decimal = Decimal('0.0') self._y_delta: Decimal = Decimal('0.0') self._row_spacing: Decimal = Decimal('0.0') self._laser_frequency: Decimal = Decimal('2000.0') # Default: 2000 Hz self._scan_velocity: Decimal = Decimal('100.0') # Default: 100 mm/s self._scan_acceleration: Decimal = Decimal('0.0') self._scan_angles: int = 0 self._points_required: int = 0 self._rows_required: int = 0 self._points_per_line: int = 0 # Optical axis centerline in stage coordinates # Stage: MLS203-1 self._optical_x_origin: Decimal = Decimal('55.0') self._optical_y_origin: Decimal = Decimal('37.5') # Data storage self._scan_coordinates: List[List[Decimal]] = [] self._scan_velocities: List[List[Decimal]] = [] self._scan_accelerations: List[List[Decimal]] = [] self._rotated_coordinates: List[List[List[Decimal]]] = [] # Constants self._deg2rad: float = math.pi / 180.0 # Properties @property def x_origin(self) -> Decimal: """X coordinate of scan origin (mm)""" return self._x_origin @x_origin.setter def x_origin(self, value: Decimal): try: self._x_origin = Decimal(str(value)) except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid x_origin value: {value}") from e @property def y_origin(self) -> Decimal: """Y coordinate of scan origin (mm)""" return self._y_origin @y_origin.setter def y_origin(self, value: Decimal): try: self._y_origin = Decimal(str(value)) except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid y_origin value: {value}") from e @property def x_delta(self) -> Decimal: """Total X distance to scan (mm)""" return self._x_delta @x_delta.setter def x_delta(self, value: Decimal): try: val = Decimal(str(value)) if val < 0: raise ValueError("x_delta must be non-negative") self._x_delta = val self.calculate_points_per_line() self.calculate_points_required() except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid x_delta value: {value}") from e @property def y_delta(self) -> Decimal: """Total Y distance to scan (mm)""" return self._y_delta @y_delta.setter def y_delta(self, value: Decimal): try: val = Decimal(str(value)) if val < 0: raise ValueError("y_delta must be non-negative") self._y_delta = val self.calculate_rows_required() self.calculate_points_required() except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid y_delta value: {value}") from e @property def row_spacing(self) -> Decimal: """Spacing between scan rows (mm)""" return self._row_spacing @row_spacing.setter def row_spacing(self, value: Decimal): try: val = Decimal(str(value)) if val < 0: raise ValueError("row_spacing must be non-negative") self._row_spacing = val self.calculate_rows_required() self.calculate_points_required() except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid row_spacing value: {value}") from e @property def laser_frequency(self) -> Decimal: """Laser pulse frequency (Hz)""" return self._laser_frequency @laser_frequency.setter def laser_frequency(self, value: Decimal): try: val = Decimal(str(value)) if val <= 0: raise ValueError("laser_frequency must be positive") self._laser_frequency = val self.calculate_points_per_line() self.calculate_points_required() except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid laser_frequency value: {value}") from e @property def scan_velocity(self) -> Decimal: """Scan velocity (mm/s)""" return self._scan_velocity @scan_velocity.setter def scan_velocity(self, value: Decimal): try: val = Decimal(str(value)) if val <= 0: raise ValueError("scan_velocity must be positive") self._scan_velocity = val self.calculate_points_per_line() self.calculate_points_required() except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid scan_velocity value: {value}") from e @property def scan_acceleration(self) -> Decimal: """Scan acceleration (mm/s²)""" return self._scan_acceleration @scan_acceleration.setter def scan_acceleration(self, value: Decimal): try: val = Decimal(str(value)) if val < 0: raise ValueError("scan_acceleration must be non-negative") self._scan_acceleration = val except (ValueError, InvalidOperation) as e: raise ValueError(f"Invalid scan_acceleration value: {value}") from e @property def scan_angles(self) -> int: """Number of scan angles to compute""" return self._scan_angles @scan_angles.setter def scan_angles(self, value: int): if value < 0: raise ValueError("scan_angles must be non-negative") self._scan_angles = value # Only compute rotated scans if we have base scan coordinates if self._scan_coordinates: self.compute_rotated_scans() @property def points_required(self) -> int: """Total number of points required for a single angle scan (computed)""" return self._points_required @property def rows_required(self) -> int: """Number of rows required for the scan (computed)""" return self._rows_required @property def points_per_line(self) -> int: """Number of points per scan line (computed)""" return self._points_per_line @property def scan_coordinates(self) -> List[List[Decimal]]: """List of scan coordinates [x_start, y_start, x_end, y_end] in mm""" return self._scan_coordinates @property def scan_velocities(self) -> List[List[Decimal]]: """List of velocity vectors [vx, vy] in mm/s for each angle""" return self._scan_velocities @property def scan_accelerations(self) -> List[List[Decimal]]: """List of acceleration vectors [ax, ay] in mm/s² for each angle""" return self._scan_accelerations @property def rotated_coordinates(self) -> List[List[List[Decimal]]]: """List of rotated scan coordinates for each angle in mm""" return self._rotated_coordinates @property def optical_x_origin(self) -> Decimal: """X coordinate of optical axis origin in mm (read-only, MLS203-1 stage)""" return self._optical_x_origin @property def optical_y_origin(self) -> Decimal: """Y coordinate of optical axis origin in mm (read-only, MLS203-1 stage)""" return self._optical_y_origin # Calculation Methods def calculate_points_per_line(self): """ Calculates the number of data points per scan line based on x_delta, scan_velocity, and laser_frequency. Formula: points = (distance / velocity) * frequency """ if self._scan_velocity != 0: self._points_per_line = int( (self._x_delta / self._scan_velocity) * self._laser_frequency ) def calculate_points_required(self): """ Calculates the total number of data points for a complete single-angle scan. Also triggers computation of the zero-angle scan coordinates. Formula: total_points = points_per_line * rows_required """ if self._rows_required != 0: self._points_required = self._points_per_line * self._rows_required self.compute_zero_scan() def calculate_rows_required(self): """ Calculates the number of scan rows needed based on y_delta and row_spacing. Formula: rows = ceil(y_delta / row_spacing) """ if self._row_spacing == 0: self._rows_required = 0 else: self._rows_required = int(math.ceil(self._y_delta / self._row_spacing)) def compute_zero_scan(self): """ Computes the zero-angle (reference) scan coordinates. Each coordinate is [x_start, y_start, x_end, y_end]. """ # Ignore the zero-row case if self._rows_required == 0: return y_offset = Decimal('0.0') self._scan_coordinates.clear() for row in range(self._rows_required + 1): # Calculate x/y origin/delta for each needed row y_offset = Decimal(row) * self._row_spacing coords = [ self._x_origin, # x_start self._y_origin + y_offset, # y_start self._x_origin + self._x_delta, # x_end self._y_origin + y_offset # y_end (same as y_start for horizontal scan) ] self._scan_coordinates.append(coords) def _rotate_point(self, x: Decimal, y: Decimal, cosine: Decimal, sine: Decimal) -> tuple: """ Rotate a point around the optical axis origin. Args: x, y: Point coordinates to rotate cosine, sine: Precomputed cos and sin of rotation angle Returns: Tuple of (rotated_x, rotated_y) """ # Rotation transformation: # Xr = (X - Xo)*cos(a) + (Y - Yo)*sin(a) + Xo # Yr = -(X - Xo)*sin(a) + (Y - Yo)*cos(a) + Yo x_rot = ((x - self._optical_x_origin) * cosine + (y - self._optical_y_origin) * sine + self._optical_x_origin) y_rot = (-(x - self._optical_x_origin) * sine + (y - self._optical_y_origin) * cosine + self._optical_y_origin) return (x_rot, y_rot) def _compute_rotated_aoi_bbox(self, angle_rad: float) -> tuple: """ Compute the bounding box of the rotated area of interest. Rotates the four corners of the AoI rectangle and finds the min/max extents to create a bounding box. Args: angle_rad: Rotation angle in radians Returns: Tuple of (min_x, min_y, max_x, max_y) as Decimals """ cosine = Decimal(str(math.cos(angle_rad))) sine = Decimal(str(math.sin(angle_rad))) # Define the four corners of the AoI rectangle corners = [ (self._x_origin, self._y_origin), (self._x_origin + self._x_delta, self._y_origin), (self._x_origin + self._x_delta, self._y_origin + self._y_delta), (self._x_origin, self._y_origin + self._y_delta) ] # Rotate all corners rotated_corners = [] for x, y in corners: x_rot, y_rot = self._rotate_point(x, y, cosine, sine) rotated_corners.append((x_rot, y_rot)) # Find bounding box extents x_coords = [corner[0] for corner in rotated_corners] y_coords = [corner[1] for corner in rotated_corners] return (min(x_coords), min(y_coords), max(x_coords), max(y_coords)) def compute_rotated_scans(self): """ Computes rotated scan coordinates by rotating the area of interest (AoI) and generating horizontal (+x direction) scans through the bounding box of the rotated AoI. The rotation covers 0 to 180 degrees with spacing determined by scan_angles. For each angle: 1. Rotate the AoI rectangle around the optical axis origin 2. Compute the bounding box of the rotated rectangle 3. Generate horizontal scan lines through the bounding box """ if self._rows_required == 0: return # Compute spacing between scans if self._scan_angles == 0: angle_spacing = 180 else: angle_spacing = 180 / self._scan_angles self._rotated_coordinates.clear() # Iterate through each required angle from 0 to 180 degrees i = 0 while i < 180: current_angle_radians = i * (math.pi / 180.0) # Get bounding box of rotated AoI min_x, min_y, max_x, max_y = self._compute_rotated_aoi_bbox(current_angle_radians) # Calculate the y extent of the bounding box y_extent = max_y - min_y # Determine number of rows needed for this bounding box if self._row_spacing == 0: num_rows = 0 else: num_rows = int(math.ceil(y_extent / self._row_spacing)) temp_list = [] # Generate horizontal scan lines through the bounding box for row in range(num_rows + 1): y_offset = Decimal(row) * self._row_spacing y_pos = min_y + y_offset # Create horizontal scan line at this y position scan_line = [ min_x, # x_start y_pos, # y_start max_x, # x_end y_pos # y_end (same as y_start for horizontal scan) ] temp_list.append(scan_line) self._rotated_coordinates.append(temp_list) i += int(round(angle_spacing)) def compute_kinematics(self, offset: int = 0): """ Computes velocity and acceleration component vectors for each scan angle. For each angle, decomposes the scalar velocity and acceleration into X and Y components based on the scan direction angle. Args: offset: Angle offset in degrees (default: 0) """ if self._scan_angles == 0: scan_increment = 180 else: scan_increment = 180 // self._scan_angles self._scan_velocities.clear() self._scan_accelerations.clear() for i in range(self._scan_angles): deg_angle = scan_increment * i + offset angle_rad = deg_angle * self._deg2rad # Compute velocity components: V = V_mag * [cos(θ), sin(θ)] velocities = [ Decimal(str(math.cos(angle_rad))) * self._scan_velocity, Decimal(str(math.sin(angle_rad))) * self._scan_velocity ] # Compute acceleration components: A = A_mag * [cos(θ), sin(θ)] accels = [ Decimal(str(math.cos(angle_rad))) * self._scan_acceleration, Decimal(str(math.sin(angle_rad))) * self._scan_acceleration ] self._scan_velocities.append(velocities) self._scan_accelerations.append(accels) # Export Methods def export_zero_scan_csv(self, filename: Optional[str] = None) -> str: """ Export the zero-angle scan coordinates to a CSV file. Args: filename: Output filename. If None, generates from row count. Returns: The filename that was written Raises: ValueError: If no scan coordinates have been computed IOError: If file cannot be written """ if not self._scan_coordinates: raise ValueError("No scan coordinates available. Configure scan parameters first.") if filename is None: filename = f"scantest-{self._rows_required}rows.csv" try: with open(filename, 'w', newline='') as f: writer = csv.writer(f) for coords in self._scan_coordinates: writer.writerow([str(c) for c in coords]) except IOError as e: raise IOError(f"Failed to write file {filename}: {e}") from e return filename def export_rotated_scan_csv(self, angle_index: int, filename: Optional[str] = None) -> str: """ Export a specific rotated scan to CSV. Args: angle_index: Index of the angle to export (0-based) filename: Output filename. If None, generates from angle and row count. Returns: The filename that was written Raises: ValueError: If angle_index is invalid or no rotated coordinates exist IOError: If file cannot be written """ if not self._rotated_coordinates: raise ValueError("No rotated coordinates available. Set scan_angles first.") if angle_index < 0 or angle_index >= len(self._rotated_coordinates): raise ValueError( f"Invalid angle_index {angle_index}. Must be 0-{len(self._rotated_coordinates)-1}" ) if filename is None: angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180) filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv" try: with open(filename, 'w', newline='') as f: writer = csv.writer(f) for coords in self._rotated_coordinates[angle_index]: writer.writerow([str(c) for c in coords]) except IOError as e: raise IOError(f"Failed to write file {filename}: {e}") from e return filename def export_all_rotated_scans_csv(self, output_dir: str = ".") -> List[str]: """ Export all rotated scans to separate CSV files. Args: output_dir: Directory to write files to (default: current directory) Returns: List of filenames that were written Raises: ValueError: If no rotated coordinates exist IOError: If files cannot be written """ if not self._rotated_coordinates: raise ValueError("No rotated coordinates available. Set scan_angles first.") import os filenames = [] for angle_index in range(len(self._rotated_coordinates)): angle_deg = angle_index * (180 // self._scan_angles if self._scan_angles > 0 else 180) filename = f"scantest-{angle_deg:03d}deg-{self._rows_required}rows.csv" filepath = os.path.join(output_dir, filename) try: with open(filepath, 'w', newline='') as f: writer = csv.writer(f) for coords in self._rotated_coordinates[angle_index]: writer.writerow([str(c) for c in coords]) filenames.append(filepath) except IOError as e: raise IOError(f"Failed to write file {filepath}: {e}") from e return filenames def export_kinematics_csv(self, filename: Optional[str] = None) -> str: """ Export velocity and acceleration data to CSV. Format: vx, vy, ax, ay for each angle. Args: filename: Output filename. If None, generates from row count. Returns: The filename that was written Raises: ValueError: If no kinematics data has been computed IOError: If file cannot be written """ if not self._scan_velocities or not self._scan_accelerations: raise ValueError("No kinematics data available. Call compute_kinematics() first.") if filename is None: filename = f"kinematics-{self._rows_required}rows.csv" try: with open(filename, 'w', newline='') as f: writer = csv.writer(f) # Optional: write header writer.writerow(['vx', 'vy', 'ax', 'ay']) for i in range(len(self._scan_velocities)): row = [ str(self._scan_velocities[i][0]), str(self._scan_velocities[i][1]), str(self._scan_accelerations[i][0]), str(self._scan_accelerations[i][1]) ] writer.writerow(row) except IOError as e: raise IOError(f"Failed to write file {filename}: {e}") from e return filename # Utility Methods def get_angle_list(self) -> List[int]: """ Get the list of scan angles in degrees. Returns: List of angles in degrees for the configured scan_angles """ if self._scan_angles == 0: return [] scan_increment = 180 // self._scan_angles return [scan_increment * i for i in range(self._scan_angles)] def get_scan_info(self) -> dict: """ Get a dictionary with current scan configuration and computed values. Returns: Dictionary containing scan parameters and computed values """ return { 'x_origin': float(self._x_origin), 'y_origin': float(self._y_origin), 'x_delta': float(self._x_delta), 'y_delta': float(self._y_delta), 'row_spacing': float(self._row_spacing), 'laser_frequency': float(self._laser_frequency), 'scan_velocity': float(self._scan_velocity), 'scan_acceleration': float(self._scan_acceleration), 'scan_angles': self._scan_angles, 'points_per_line': self._points_per_line, 'rows_required': self._rows_required, 'points_required': self._points_required, 'optical_x_origin': float(self._optical_x_origin), 'optical_y_origin': float(self._optical_y_origin), 'num_scan_coordinates': len(self._scan_coordinates), 'num_rotated_angles': len(self._rotated_coordinates), 'angle_list': self.get_angle_list(), } def __repr__(self) -> str: """String representation of the scan model.""" return ( f"SC3ScanModel(" f"origin=({self._x_origin},{self._y_origin}), " f"delta=({self._x_delta},{self._y_delta}), " f"rows={self._rows_required}, " f"angles={self._scan_angles})" )