pattern_manager.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import os
  2. import threading
  3. import time
  4. import random
  5. import logging
  6. from datetime import datetime
  7. from tqdm import tqdm
  8. from modules.connection import connection_manager
  9. from modules.core.state import state
  10. from math import pi
  11. import asyncio
  12. import json
  13. # Configure logging
  14. logger = logging.getLogger(__name__)
  15. # Global state
  16. THETA_RHO_DIR = './patterns'
  17. CLEAR_PATTERNS = {
  18. "clear_from_in": "./patterns/clear_from_in.thr",
  19. "clear_from_out": "./patterns/clear_from_out.thr",
  20. "clear_sideway": "./patterns/clear_sideway.thr"
  21. }
  22. os.makedirs(THETA_RHO_DIR, exist_ok=True)
  23. # Create an asyncio Event for pause/resume
  24. pause_event = asyncio.Event()
  25. pause_event.set() # Initially not paused
  26. # Create an asyncio Lock for pattern execution
  27. pattern_lock = asyncio.Lock()
  28. # Progress update task
  29. progress_update_task = None
  30. def list_theta_rho_files():
  31. files = []
  32. for root, _, filenames in os.walk(THETA_RHO_DIR):
  33. for file in filenames:
  34. relative_path = os.path.relpath(os.path.join(root, file), THETA_RHO_DIR)
  35. files.append(relative_path)
  36. logger.debug(f"Found {len(files)} theta-rho files")
  37. return files
  38. def parse_theta_rho_file(file_path):
  39. """Parse a theta-rho file and return a list of (theta, rho) pairs."""
  40. coordinates = []
  41. try:
  42. logger.debug(f"Parsing theta-rho file: {file_path}")
  43. with open(file_path, 'r') as file:
  44. for line in file:
  45. line = line.strip()
  46. if not line or line.startswith("#"):
  47. continue
  48. try:
  49. theta, rho = map(float, line.split())
  50. coordinates.append((theta, rho))
  51. except ValueError:
  52. logger.warning(f"Skipping invalid line: {line}")
  53. continue
  54. except Exception as e:
  55. logger.error(f"Error reading file: {e}")
  56. return coordinates
  57. # Normalization Step
  58. if coordinates:
  59. first_theta = coordinates[0][0]
  60. normalized = [(theta - first_theta, rho) for theta, rho in coordinates]
  61. coordinates = normalized
  62. logger.debug(f"Parsed {len(coordinates)} coordinates from {file_path}")
  63. return coordinates
  64. def get_clear_pattern_file(clear_pattern_mode, path=None):
  65. """Return a .thr file path based on pattern_name."""
  66. if not clear_pattern_mode or clear_pattern_mode == 'none':
  67. return
  68. logger.info("Clear pattern mode: " + clear_pattern_mode)
  69. if clear_pattern_mode == "random":
  70. return random.choice(list(CLEAR_PATTERNS.values()))
  71. if clear_pattern_mode == 'adaptive':
  72. if not path:
  73. logger.warning("No path provided for adaptive clear pattern")
  74. return random.choice(list(CLEAR_PATTERNS.values()))
  75. coordinates = parse_theta_rho_file(path)
  76. if not coordinates:
  77. logger.warning("No valid coordinates found in file for adaptive clear pattern")
  78. return random.choice(list(CLEAR_PATTERNS.values()))
  79. first_rho = coordinates[0][1]
  80. if first_rho < 0.5:
  81. return CLEAR_PATTERNS['clear_from_out']
  82. else:
  83. return random.choice([CLEAR_PATTERNS['clear_from_in'], CLEAR_PATTERNS['clear_sideway']])
  84. else:
  85. if clear_pattern_mode not in CLEAR_PATTERNS:
  86. logger.warning(f"Invalid clear pattern mode: {clear_pattern_mode}")
  87. return random.choice(list(CLEAR_PATTERNS.values()))
  88. return CLEAR_PATTERNS[clear_pattern_mode]
  89. async def run_theta_rho_file(file_path):
  90. """Run a theta-rho file by sending data in optimized batches with tqdm ETA tracking."""
  91. if pattern_lock.locked():
  92. logger.warning("Another pattern is already running. Cannot start a new one.")
  93. return
  94. async with pattern_lock: # This ensures only one pattern can run at a time
  95. # Start progress update task
  96. global progress_update_task
  97. progress_update_task = asyncio.create_task(broadcast_progress())
  98. coordinates = parse_theta_rho_file(file_path)
  99. total_coordinates = len(coordinates)
  100. if total_coordinates < 2:
  101. logger.warning("Not enough coordinates for interpolation")
  102. state.current_playing_file = None
  103. state.execution_progress = None
  104. return
  105. state.execution_progress = (0, total_coordinates, None, 0)
  106. # stop actions without resetting the playlist
  107. stop_actions(clear_playlist=False)
  108. state.current_playing_file = file_path
  109. state.stop_requested = False
  110. logger.info(f"Starting pattern execution: {file_path}")
  111. logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
  112. reset_theta()
  113. start_time = time.time()
  114. with tqdm(
  115. total=total_coordinates,
  116. unit="coords",
  117. desc=f"Executing Pattern {file_path}",
  118. dynamic_ncols=True,
  119. disable=False,
  120. mininterval=1.0
  121. ) as pbar:
  122. for i, coordinate in enumerate(coordinates):
  123. theta, rho = coordinate
  124. if state.stop_requested:
  125. logger.info("Execution stopped by user")
  126. break
  127. # Wait for resume if paused
  128. if state.pause_requested:
  129. logger.info("Execution paused...")
  130. await pause_event.wait()
  131. logger.info("Execution resumed...")
  132. move_polar(theta, rho)
  133. if i != 0:
  134. pbar.update(1)
  135. elapsed_time = time.time() - start_time
  136. estimated_remaining_time = (total_coordinates - i) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  137. state.execution_progress = (i, total_coordinates, estimated_remaining_time, elapsed_time)
  138. # Add a small delay to allow other async operations
  139. await asyncio.sleep(0.001)
  140. connection_manager.check_idle()
  141. state.current_playing_file = None
  142. state.execution_progress = None
  143. logger.info("Pattern execution completed")
  144. # Cancel progress update task
  145. if progress_update_task:
  146. progress_update_task.cancel()
  147. try:
  148. await progress_update_task
  149. except asyncio.CancelledError:
  150. pass
  151. async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  152. """Run multiple .thr files in sequence with options."""
  153. state.stop_requested = False
  154. # Set initial playlist state
  155. state.playlist_mode = run_mode
  156. state.current_playlist_index = 0
  157. if shuffle:
  158. random.shuffle(file_paths)
  159. logger.info("Playlist shuffled")
  160. try:
  161. while True:
  162. for idx, path in enumerate(file_paths):
  163. logger.info(f"Upcoming pattern: {path}")
  164. state.current_playlist_index = idx
  165. if state.stop_requested:
  166. logger.info("Execution stopped before starting next pattern")
  167. return
  168. if clear_pattern:
  169. if state.stop_requested:
  170. logger.info("Execution stopped before running the next clear pattern")
  171. return
  172. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  173. logger.info(f"Running clear pattern: {clear_file_path}")
  174. await run_theta_rho_file(clear_file_path)
  175. if not state.stop_requested:
  176. logger.info(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
  177. await run_theta_rho_file(path)
  178. if idx < len(file_paths) - 1:
  179. if state.stop_requested:
  180. logger.info("Execution stopped before running the next clear pattern")
  181. return
  182. if pause_time > 0:
  183. logger.info(f"Pausing for {pause_time} seconds")
  184. await asyncio.sleep(pause_time)
  185. if run_mode == "indefinite":
  186. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  187. if pause_time > 0:
  188. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  189. await asyncio.sleep(pause_time)
  190. if shuffle:
  191. random.shuffle(file_paths)
  192. logger.info("Playlist reshuffled for the next loop")
  193. continue
  194. else:
  195. logger.info("Playlist completed")
  196. break
  197. finally:
  198. state.current_playlist = None
  199. state.current_playlist_index = None
  200. state.playlist_mode = None
  201. logger.info("All requested patterns completed (or stopped)")
  202. def stop_actions(clear_playlist = True):
  203. """Stop all current actions."""
  204. with state.pause_condition:
  205. state.pause_requested = False
  206. state.stop_requested = True
  207. state.current_playing_file = None
  208. state.execution_progress = None
  209. state.is_clearing = False
  210. if clear_playlist:
  211. # Clear playlist state
  212. state.current_playlist = None
  213. state.current_playlist_index = None
  214. state.playlist_mode = None
  215. state.pause_condition.notify_all()
  216. connection_manager.update_machine_position()
  217. def move_polar(theta, rho):
  218. """
  219. This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
  220. and translate the motion to gcode jog command and sent to grbl.
  221. Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the
  222. x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
  223. X axis (angular): 50mm = 1 revolution
  224. Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
  225. Args:
  226. theta (_type_): _description_
  227. rho (_type_): _description_
  228. """
  229. # Adding soft limit to reduce hardware sound
  230. soft_limit_inner = 0.01
  231. if rho < soft_limit_inner:
  232. rho = soft_limit_inner
  233. soft_limit_outter = 0.015
  234. if rho > (1-soft_limit_outter):
  235. rho = (1-soft_limit_outter)
  236. if state.gear_ratio == 6.25:
  237. x_scaling_factor = 2
  238. y_scaling_factor = 3.7
  239. else:
  240. x_scaling_factor = 2
  241. y_scaling_factor = 5
  242. delta_theta = theta - state.current_theta
  243. delta_rho = rho - state.current_rho
  244. x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor) # Added -1 to reverse direction
  245. y_increment = delta_rho * 100 / y_scaling_factor
  246. x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
  247. y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
  248. offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
  249. if state.gear_ratio == 6.25:
  250. y_increment -= offset
  251. else:
  252. y_increment += offset
  253. new_x_abs = state.machine_x + x_increment
  254. new_y_abs = state.machine_y + y_increment
  255. # dynamic_speed = compute_dynamic_speed(rho, max_speed=state.speed)
  256. connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), state.speed)
  257. state.current_theta = theta
  258. state.current_rho = rho
  259. state.machine_x = new_x_abs
  260. state.machine_y = new_y_abs
  261. def pause_execution():
  262. """Pause pattern execution using asyncio Event."""
  263. logger.info("Pausing pattern execution")
  264. state.pause_requested = True
  265. pause_event.clear() # Clear the event to pause execution
  266. return True
  267. def resume_execution():
  268. """Resume pattern execution using asyncio Event."""
  269. logger.info("Resuming pattern execution")
  270. state.pause_requested = False
  271. pause_event.set() # Set the event to resume execution
  272. return True
  273. def reset_theta():
  274. logger.info('Resetting Theta')
  275. state.current_theta = 0
  276. connection_manager.update_machine_position()
  277. def set_speed(new_speed):
  278. state.speed = new_speed
  279. logger.info(f'Set new state.speed {new_speed}')
  280. def get_status():
  281. """Get the current status of pattern execution."""
  282. status = {
  283. "current_file": state.current_playing_file,
  284. "is_paused": state.pause_requested,
  285. "is_running": bool(state.current_playing_file and not state.stop_requested),
  286. "progress": None
  287. }
  288. if state.execution_progress:
  289. current, total, remaining_time, elapsed_time = state.execution_progress
  290. status["progress"] = {
  291. "current": current,
  292. "total": total,
  293. "remaining_time": remaining_time,
  294. "elapsed_time": elapsed_time,
  295. "percentage": (current / total * 100) if total > 0 else 0
  296. }
  297. return status
  298. async def broadcast_progress():
  299. """Background task to broadcast progress updates."""
  300. from app import active_status_connections
  301. while True:
  302. if not pattern_lock.locked():
  303. # No pattern running, stop the task
  304. break
  305. status = get_status()
  306. disconnected = set()
  307. for websocket in active_status_connections:
  308. try:
  309. await websocket.send_json(status)
  310. except Exception:
  311. disconnected.add(websocket)
  312. # Clean up disconnected clients
  313. active_status_connections.difference_update(disconnected)
  314. # Wait before next update
  315. await asyncio.sleep(0.5)