|
| 1 | +""" |
| 2 | +Motor configuration data structures for unified motor parameter management. |
| 3 | +This module provides dataclasses for storing motor settings per axis, |
| 4 | +including motion parameters, homing configuration, and TMC driver settings. |
| 5 | +""" |
| 6 | + |
| 7 | +import json |
| 8 | +from dataclasses import dataclass, field, asdict |
| 9 | +from typing import Optional, Dict, List |
| 10 | + |
| 11 | + |
| 12 | +@dataclass |
| 13 | +class TMCSettings: |
| 14 | + """TMC stepper driver settings for a single axis.""" |
| 15 | + msteps: int = 16 # Microsteps (1, 2, 4, 8, 16, 32, 64, 128, 256) |
| 16 | + rms_current: int = 500 # RMS current in mA |
| 17 | + sgthrs: int = 10 # StallGuard threshold |
| 18 | + semin: int = 5 # Minimum coolstep current |
| 19 | + semax: int = 2 # Maximum coolstep current |
| 20 | + blank_time: int = 24 # Comparator blank time |
| 21 | + toff: int = 3 # Off time |
| 22 | + |
| 23 | + def to_dict(self) -> dict: |
| 24 | + return asdict(self) |
| 25 | + |
| 26 | + @classmethod |
| 27 | + def from_dict(cls, data: dict) -> 'TMCSettings': |
| 28 | + if data is None: |
| 29 | + return cls() |
| 30 | + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) |
| 31 | + |
| 32 | + |
| 33 | +@dataclass |
| 34 | +class HomingSettings: |
| 35 | + """Homing configuration for a single axis.""" |
| 36 | + enabled: bool = False # Is homing enabled for this axis |
| 37 | + speed: int = 15000 # Homing speed in steps/s |
| 38 | + direction: int = -1 # Homing direction (-1 or 1) |
| 39 | + endstop_polarity: int = 1 # Endstop polarity (0=NO, 1=NC) |
| 40 | + endpos_release: int = 3000 # Steps to back off after hitting endstop |
| 41 | + timeout: int = 20000 # Homing timeout in ms |
| 42 | + home_on_start: bool = False # Home this axis on startup |
| 43 | + home_steps: int = 0 # Steps to move if no endstop (open-loop homing) |
| 44 | + |
| 45 | + def to_dict(self) -> dict: |
| 46 | + return asdict(self) |
| 47 | + |
| 48 | + @classmethod |
| 49 | + def from_dict(cls, data: dict) -> 'HomingSettings': |
| 50 | + if data is None: |
| 51 | + return cls() |
| 52 | + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) |
| 53 | + |
| 54 | + |
| 55 | +@dataclass |
| 56 | +class MotionSettings: |
| 57 | + """Motion parameters for a single axis.""" |
| 58 | + step_size: float = 1.0 # Steps per µm (calibrated stepsize) |
| 59 | + min_pos: float = float('-inf') # Minimum position limit |
| 60 | + max_pos: float = float('inf') # Maximum position limit |
| 61 | + backlash: int = 0 # Backlash compensation in steps |
| 62 | + max_speed: int = 10000 # Maximum speed in steps/s |
| 63 | + initial_speed: int = 10000 # Initial/default speed in steps/s |
| 64 | + acceleration: int = 1000000 # Acceleration in steps/s² |
| 65 | + |
| 66 | + def to_dict(self) -> dict: |
| 67 | + # Handle inf values for JSON serialization |
| 68 | + d = asdict(self) |
| 69 | + if d['min_pos'] == float('-inf'): |
| 70 | + d['min_pos'] = None |
| 71 | + if d['max_pos'] == float('inf'): |
| 72 | + d['max_pos'] = None |
| 73 | + return d |
| 74 | + |
| 75 | + @classmethod |
| 76 | + def from_dict(cls, data: dict) -> 'MotionSettings': |
| 77 | + if data is None: |
| 78 | + return cls() |
| 79 | + # Handle None values from JSON |
| 80 | + if data.get('min_pos') is None: |
| 81 | + data['min_pos'] = float('-inf') |
| 82 | + if data.get('max_pos') is None: |
| 83 | + data['max_pos'] = float('inf') |
| 84 | + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) |
| 85 | + |
| 86 | + |
| 87 | +@dataclass |
| 88 | +class SoftLimitSettings: |
| 89 | + """Soft limit configuration for a single axis.""" |
| 90 | + enabled: bool = False |
| 91 | + min_pos: int = 0 |
| 92 | + max_pos: int = 0 |
| 93 | + |
| 94 | + def to_dict(self) -> dict: |
| 95 | + return asdict(self) |
| 96 | + |
| 97 | + @classmethod |
| 98 | + def from_dict(cls, data: dict) -> 'SoftLimitSettings': |
| 99 | + if data is None: |
| 100 | + return cls() |
| 101 | + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) |
| 102 | + |
| 103 | + |
| 104 | +@dataclass |
| 105 | +class HardLimitSettings: |
| 106 | + """Hard limit (emergency stop) configuration for a single axis.""" |
| 107 | + enabled: bool = True # Hard limits enabled by default |
| 108 | + polarity: int = 0 # 0=NO (Normally Open), 1=NC (Normally Closed) |
| 109 | + |
| 110 | + def to_dict(self) -> dict: |
| 111 | + return asdict(self) |
| 112 | + |
| 113 | + @classmethod |
| 114 | + def from_dict(cls, data: dict) -> 'HardLimitSettings': |
| 115 | + if data is None: |
| 116 | + return cls() |
| 117 | + return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) |
| 118 | + |
| 119 | + |
| 120 | +@dataclass |
| 121 | +class AxisConfig: |
| 122 | + """Complete configuration for a single motor axis.""" |
| 123 | + axis: str # Axis name: "X", "Y", "Z", or "A" |
| 124 | + motion: MotionSettings = field(default_factory=MotionSettings) |
| 125 | + homing: HomingSettings = field(default_factory=HomingSettings) |
| 126 | + tmc: TMCSettings = field(default_factory=TMCSettings) |
| 127 | + soft_limits: SoftLimitSettings = field(default_factory=SoftLimitSettings) |
| 128 | + hard_limits: HardLimitSettings = field(default_factory=HardLimitSettings) |
| 129 | + joystick_inverted: bool = False |
| 130 | + |
| 131 | + def to_dict(self) -> dict: |
| 132 | + return { |
| 133 | + 'axis': self.axis, |
| 134 | + 'motion': self.motion.to_dict(), |
| 135 | + 'homing': self.homing.to_dict(), |
| 136 | + 'tmc': self.tmc.to_dict(), |
| 137 | + 'soft_limits': self.soft_limits.to_dict(), |
| 138 | + 'hard_limits': self.hard_limits.to_dict(), |
| 139 | + 'joystick_inverted': self.joystick_inverted |
| 140 | + } |
| 141 | + |
| 142 | + @classmethod |
| 143 | + def from_dict(cls, data: dict) -> 'AxisConfig': |
| 144 | + if data is None: |
| 145 | + raise ValueError("Axis data cannot be None") |
| 146 | + return cls( |
| 147 | + axis=data.get('axis', 'X'), |
| 148 | + motion=MotionSettings.from_dict(data.get('motion')), |
| 149 | + homing=HomingSettings.from_dict(data.get('homing')), |
| 150 | + tmc=TMCSettings.from_dict(data.get('tmc')), |
| 151 | + soft_limits=SoftLimitSettings.from_dict(data.get('soft_limits')), |
| 152 | + hard_limits=HardLimitSettings.from_dict(data.get('hard_limits')), |
| 153 | + joystick_inverted=data.get('joystick_inverted', False) |
| 154 | + ) |
| 155 | + |
| 156 | + |
| 157 | +@dataclass |
| 158 | +class MotorSystemConfig: |
| 159 | + """Complete motor system configuration for all axes.""" |
| 160 | + axes: Dict[str, AxisConfig] = field(default_factory=dict) |
| 161 | + axis_order: List[int] = field(default_factory=lambda: [0, 1, 2, 3]) # A, X, Y, Z mapping |
| 162 | + is_corexy: bool = False |
| 163 | + is_enabled: bool = True |
| 164 | + enable_auto: bool = True |
| 165 | + is_dual_axis: bool = False # Dual axis mode (A and Z linked) |
| 166 | + |
| 167 | + def __post_init__(self): |
| 168 | + # Initialize default axes if not provided |
| 169 | + if not self.axes: |
| 170 | + for axis_name in ['A', 'X', 'Y', 'Z']: |
| 171 | + self.axes[axis_name] = AxisConfig(axis=axis_name) |
| 172 | + |
| 173 | + def get_axis(self, axis: str) -> AxisConfig: |
| 174 | + """Get configuration for a specific axis.""" |
| 175 | + axis = axis.upper() |
| 176 | + if axis not in self.axes: |
| 177 | + self.axes[axis] = AxisConfig(axis=axis) |
| 178 | + return self.axes[axis] |
| 179 | + |
| 180 | + def set_axis(self, axis: str, config: AxisConfig): |
| 181 | + """Set configuration for a specific axis.""" |
| 182 | + axis = axis.upper() |
| 183 | + self.axes[axis] = config |
| 184 | + |
| 185 | + def to_dict(self) -> dict: |
| 186 | + return { |
| 187 | + 'axes': {k: v.to_dict() for k, v in self.axes.items()}, |
| 188 | + 'axis_order': self.axis_order, |
| 189 | + 'is_corexy': self.is_corexy, |
| 190 | + 'is_enabled': self.is_enabled, |
| 191 | + 'enable_auto': self.enable_auto, |
| 192 | + 'is_dual_axis': self.is_dual_axis |
| 193 | + } |
| 194 | + |
| 195 | + @classmethod |
| 196 | + def from_dict(cls, data: dict) -> 'MotorSystemConfig': |
| 197 | + if data is None: |
| 198 | + return cls() |
| 199 | + axes = {} |
| 200 | + if 'axes' in data: |
| 201 | + for axis_name, axis_data in data['axes'].items(): |
| 202 | + axes[axis_name] = AxisConfig.from_dict(axis_data) |
| 203 | + return cls( |
| 204 | + axes=axes, |
| 205 | + axis_order=data.get('axis_order', [0, 1, 2, 3]), |
| 206 | + is_corexy=data.get('is_corexy', False), |
| 207 | + is_enabled=data.get('is_enabled', True), |
| 208 | + enable_auto=data.get('enable_auto', True), |
| 209 | + is_dual_axis=data.get('is_dual_axis', False) |
| 210 | + ) |
| 211 | + |
| 212 | + def to_json(self) -> str: |
| 213 | + """Serialize to JSON string.""" |
| 214 | + return json.dumps(self.to_dict(), indent=2) |
| 215 | + |
| 216 | + @classmethod |
| 217 | + def from_json(cls, json_str: str) -> 'MotorSystemConfig': |
| 218 | + """Deserialize from JSON string.""" |
| 219 | + return cls.from_dict(json.loads(json_str)) |
| 220 | + |
| 221 | + @classmethod |
| 222 | + def from_imswitch_config(cls, manager_properties: dict, stage_offsets: dict = None) -> 'MotorSystemConfig': |
| 223 | + """ |
| 224 | + Create MotorSystemConfig from ImSwitch positionerInfo.managerProperties format. |
| 225 | + |
| 226 | + This converts the existing ImSwitch configuration format to the unified format. |
| 227 | + """ |
| 228 | + config = cls() |
| 229 | + |
| 230 | + # Global settings |
| 231 | + config.axis_order = manager_properties.get('axisOrder', [0, 1, 2, 3]) |
| 232 | + config.is_corexy = manager_properties.get('isCoreXY', False) |
| 233 | + config.is_enabled = manager_properties.get('isEnable', True) |
| 234 | + config.enable_auto = manager_properties.get('enableauto', True) |
| 235 | + config.is_dual_axis = manager_properties.get('isDualaxis', False) |
| 236 | + |
| 237 | + # Per-axis settings |
| 238 | + for axis in ['X', 'Y', 'Z', 'A']: |
| 239 | + axis_config = config.get_axis(axis) |
| 240 | + |
| 241 | + # Motion settings |
| 242 | + axis_config.motion.step_size = manager_properties.get(f'stepsize{axis}', 1) |
| 243 | + axis_config.motion.min_pos = manager_properties.get(f'min{axis}', float('-inf')) |
| 244 | + axis_config.motion.max_pos = manager_properties.get(f'max{axis}', float('inf')) |
| 245 | + axis_config.motion.backlash = manager_properties.get(f'backlash{axis}', 0) |
| 246 | + axis_config.motion.max_speed = manager_properties.get(f'maxSpeed{axis}', 10000) |
| 247 | + axis_config.motion.initial_speed = manager_properties.get(f'initialSpeed{axis}', 10000) |
| 248 | + |
| 249 | + # Homing settings |
| 250 | + axis_config.homing.enabled = manager_properties.get(f'home{axis}enabled', False) |
| 251 | + axis_config.homing.speed = manager_properties.get(f'homeSpeed{axis}', 15000) |
| 252 | + axis_config.homing.direction = manager_properties.get(f'homeDirection{axis}', -1) |
| 253 | + axis_config.homing.endstop_polarity = manager_properties.get(f'homeEndstoppolarity{axis}', 1) |
| 254 | + axis_config.homing.endpos_release = manager_properties.get(f'homeEndposRelease{axis}', 3000) |
| 255 | + axis_config.homing.timeout = manager_properties.get(f'homeTimeout{axis}', 20000) |
| 256 | + axis_config.homing.home_on_start = manager_properties.get(f'homeOnStart{axis}', False) |
| 257 | + axis_config.homing.home_steps = manager_properties.get(f'homeSteps{axis}', 0) |
| 258 | + |
| 259 | + # TMC settings (if available) |
| 260 | + if manager_properties.get(f'msteps{axis}') is not None: |
| 261 | + axis_config.tmc.msteps = manager_properties.get(f'msteps{axis}', 16) |
| 262 | + axis_config.tmc.rms_current = manager_properties.get(f'rms_current{axis}', 500) |
| 263 | + axis_config.tmc.sgthrs = manager_properties.get(f'sgthrs{axis}', 10) |
| 264 | + axis_config.tmc.semin = manager_properties.get(f'semin{axis}', 5) |
| 265 | + axis_config.tmc.semax = manager_properties.get(f'semax{axis}', 2) |
| 266 | + axis_config.tmc.blank_time = manager_properties.get(f'blank_time{axis}', 24) |
| 267 | + axis_config.tmc.toff = manager_properties.get(f'toff{axis}', 3) |
| 268 | + |
| 269 | + config.set_axis(axis, axis_config) |
| 270 | + |
| 271 | + return config |
| 272 | + |
| 273 | + def to_imswitch_properties(self) -> dict: |
| 274 | + """ |
| 275 | + Convert back to ImSwitch positionerInfo.managerProperties format. |
| 276 | + """ |
| 277 | + props = { |
| 278 | + 'axisOrder': self.axis_order, |
| 279 | + 'isCoreXY': self.is_corexy, |
| 280 | + 'isEnable': self.is_enabled, |
| 281 | + 'enableauto': self.enable_auto, |
| 282 | + 'isDualaxis': self.is_dual_axis, |
| 283 | + } |
| 284 | + |
| 285 | + for axis_name, axis_config in self.axes.items(): |
| 286 | + # Motion settings |
| 287 | + props[f'stepsize{axis_name}'] = axis_config.motion.step_size |
| 288 | + if axis_config.motion.min_pos != float('-inf'): |
| 289 | + props[f'min{axis_name}'] = axis_config.motion.min_pos |
| 290 | + if axis_config.motion.max_pos != float('inf'): |
| 291 | + props[f'max{axis_name}'] = axis_config.motion.max_pos |
| 292 | + props[f'backlash{axis_name}'] = axis_config.motion.backlash |
| 293 | + props[f'maxSpeed{axis_name}'] = axis_config.motion.max_speed |
| 294 | + props[f'initialSpeed{axis_name}'] = axis_config.motion.initial_speed |
| 295 | + |
| 296 | + # Homing settings |
| 297 | + props[f'home{axis_name}enabled'] = axis_config.homing.enabled |
| 298 | + props[f'homeSpeed{axis_name}'] = axis_config.homing.speed |
| 299 | + props[f'homeDirection{axis_name}'] = axis_config.homing.direction |
| 300 | + props[f'homeEndstoppolarity{axis_name}'] = axis_config.homing.endstop_polarity |
| 301 | + props[f'homeEndposRelease{axis_name}'] = axis_config.homing.endpos_release |
| 302 | + props[f'homeTimeout{axis_name}'] = axis_config.homing.timeout |
| 303 | + props[f'homeOnStart{axis_name}'] = axis_config.homing.home_on_start |
| 304 | + props[f'homeSteps{axis_name}'] = axis_config.homing.home_steps |
| 305 | + |
| 306 | + # TMC settings |
| 307 | + props[f'msteps{axis_name}'] = axis_config.tmc.msteps |
| 308 | + props[f'rms_current{axis_name}'] = axis_config.tmc.rms_current |
| 309 | + props[f'sgthrs{axis_name}'] = axis_config.tmc.sgthrs |
| 310 | + props[f'semin{axis_name}'] = axis_config.tmc.semin |
| 311 | + props[f'semax{axis_name}'] = axis_config.tmc.semax |
| 312 | + props[f'blank_time{axis_name}'] = axis_config.tmc.blank_time |
| 313 | + props[f'toff{axis_name}'] = axis_config.tmc.toff |
| 314 | + |
| 315 | + return props |
0 commit comments