1
0

pattern_manager.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  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. # Update progress for all coordinates including the first one
  134. pbar.update(1)
  135. elapsed_time = time.time() - start_time
  136. estimated_remaining_time = (total_coordinates - (i + 1)) / pbar.format_dict['rate'] if pbar.format_dict['rate'] and total_coordinates else 0
  137. state.execution_progress = (i + 1, total_coordinates, estimated_remaining_time, elapsed_time)
  138. # Add a small delay to allow other async operations
  139. await asyncio.sleep(0.001)
  140. # Update progress one last time to show 100%
  141. elapsed_time = time.time() - start_time
  142. state.execution_progress = (total_coordinates, total_coordinates, 0, elapsed_time)
  143. # Give WebSocket a chance to send the final update
  144. await asyncio.sleep(0.1)
  145. connection_manager.check_idle()
  146. state.current_playing_file = None
  147. state.execution_progress = None
  148. logger.info("Pattern execution completed")
  149. # Cancel progress update task
  150. if progress_update_task:
  151. progress_update_task.cancel()
  152. try:
  153. await progress_update_task
  154. except asyncio.CancelledError:
  155. pass
  156. async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
  157. """Run multiple .thr files in sequence with options."""
  158. state.stop_requested = False
  159. # Set initial playlist state
  160. state.playlist_mode = run_mode
  161. state.current_playlist_index = 0
  162. if shuffle:
  163. random.shuffle(file_paths)
  164. logger.info("Playlist shuffled")
  165. try:
  166. while True:
  167. for idx, path in enumerate(file_paths):
  168. logger.info(f"Upcoming pattern: {path}")
  169. state.current_playlist_index = idx
  170. if state.stop_requested:
  171. logger.info("Execution stopped before starting next pattern")
  172. return
  173. if clear_pattern:
  174. if state.stop_requested:
  175. logger.info("Execution stopped before running the next clear pattern")
  176. return
  177. clear_file_path = get_clear_pattern_file(clear_pattern, path)
  178. logger.info(f"Running clear pattern: {clear_file_path}")
  179. await run_theta_rho_file(clear_file_path)
  180. if not state.stop_requested:
  181. logger.info(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
  182. await run_theta_rho_file(path)
  183. if idx < len(file_paths) - 1:
  184. if state.stop_requested:
  185. logger.info("Execution stopped before running the next clear pattern")
  186. return
  187. if pause_time > 0:
  188. logger.info(f"Pausing for {pause_time} seconds")
  189. await asyncio.sleep(pause_time)
  190. if run_mode == "indefinite":
  191. logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
  192. if pause_time > 0:
  193. logger.debug(f"Pausing for {pause_time} seconds before restarting")
  194. await asyncio.sleep(pause_time)
  195. if shuffle:
  196. random.shuffle(file_paths)
  197. logger.info("Playlist reshuffled for the next loop")
  198. continue
  199. else:
  200. logger.info("Playlist completed")
  201. break
  202. finally:
  203. state.current_playlist = None
  204. state.current_playlist_index = None
  205. state.playlist_mode = None
  206. logger.info("All requested patterns completed (or stopped)")
  207. def stop_actions(clear_playlist = True):
  208. """Stop all current actions."""
  209. with state.pause_condition:
  210. state.pause_requested = False
  211. state.stop_requested = True
  212. state.current_playing_file = None
  213. state.execution_progress = None
  214. state.is_clearing = False
  215. if clear_playlist:
  216. # Clear playlist state
  217. state.current_playlist = None
  218. state.current_playlist_index = None
  219. state.playlist_mode = None
  220. state.pause_condition.notify_all()
  221. connection_manager.update_machine_position()
  222. def move_polar(theta, rho):
  223. """
  224. This functions take in a pair of theta rho coordinate, compute the distance to travel based on current theta, rho,
  225. and translate the motion to gcode jog command and sent to grbl.
  226. Since having similar steps_per_mm will make x and y axis moves at around the same speed, we have to scale the
  227. x_steps_per_mm and y_steps_per_mm so that they are roughly the same. Here's the range of motion:
  228. X axis (angular): 50mm = 1 revolution
  229. Y axis (radial): 0 => 20mm = theta 0 (center) => 1 (perimeter)
  230. Args:
  231. theta (_type_): _description_
  232. rho (_type_): _description_
  233. """
  234. # Adding soft limit to reduce hardware sound
  235. soft_limit_inner = 0.01
  236. if rho < soft_limit_inner:
  237. rho = soft_limit_inner
  238. soft_limit_outter = 0.015
  239. if rho > (1-soft_limit_outter):
  240. rho = (1-soft_limit_outter)
  241. if state.gear_ratio == 6.25:
  242. x_scaling_factor = 2
  243. y_scaling_factor = 3.7
  244. else:
  245. x_scaling_factor = 2
  246. y_scaling_factor = 5
  247. delta_theta = theta - state.current_theta
  248. delta_rho = rho - state.current_rho
  249. x_increment = delta_theta * 100 / (2 * pi * x_scaling_factor) # Added -1 to reverse direction
  250. y_increment = delta_rho * 100 / y_scaling_factor
  251. x_total_steps = state.x_steps_per_mm * (100/x_scaling_factor)
  252. y_total_steps = state.y_steps_per_mm * (100/y_scaling_factor)
  253. offset = x_increment * (x_total_steps * x_scaling_factor / (state.gear_ratio * y_total_steps * y_scaling_factor))
  254. if state.gear_ratio == 6.25:
  255. y_increment -= offset
  256. else:
  257. y_increment += offset
  258. new_x_abs = state.machine_x + x_increment
  259. new_y_abs = state.machine_y + y_increment
  260. # dynamic_speed = compute_dynamic_speed(rho, max_speed=state.speed)
  261. connection_manager.send_grbl_coordinates(round(new_x_abs, 3), round(new_y_abs,3), state.speed)
  262. state.current_theta = theta
  263. state.current_rho = rho
  264. state.machine_x = new_x_abs
  265. state.machine_y = new_y_abs
  266. def pause_execution():
  267. """Pause pattern execution using asyncio Event."""
  268. logger.info("Pausing pattern execution")
  269. state.pause_requested = True
  270. pause_event.clear() # Clear the event to pause execution
  271. return True
  272. def resume_execution():
  273. """Resume pattern execution using asyncio Event."""
  274. logger.info("Resuming pattern execution")
  275. state.pause_requested = False
  276. pause_event.set() # Set the event to resume execution
  277. return True
  278. def reset_theta():
  279. logger.info('Resetting Theta')
  280. state.current_theta = 0
  281. connection_manager.update_machine_position()
  282. def set_speed(new_speed):
  283. state.speed = new_speed
  284. logger.info(f'Set new state.speed {new_speed}')
  285. def get_status():
  286. """Get the current status of pattern execution."""
  287. status = {
  288. "current_file": state.current_playing_file,
  289. "is_paused": state.pause_requested,
  290. "is_running": bool(state.current_playing_file and not state.stop_requested),
  291. "progress": None,
  292. "playlist": None
  293. }
  294. # Add playlist information if available
  295. if state.current_playlist and state.current_playlist_index is not None:
  296. next_index = state.current_playlist_index + 1
  297. status["playlist"] = {
  298. "current_index": state.current_playlist_index,
  299. "total_files": len(state.current_playlist),
  300. "mode": state.playlist_mode,
  301. "next_file": state.current_playlist[next_index] if next_index < len(state.current_playlist) else (state.current_playlist[0] if state.playlist_mode == "loop" else None)
  302. }
  303. if state.execution_progress:
  304. current, total, remaining_time, elapsed_time = state.execution_progress
  305. status["progress"] = {
  306. "current": current,
  307. "total": total,
  308. "remaining_time": remaining_time,
  309. "elapsed_time": elapsed_time,
  310. "percentage": (current / total * 100) if total > 0 else 0
  311. }
  312. return status
  313. async def broadcast_progress():
  314. """Background task to broadcast progress updates."""
  315. from app import active_status_connections
  316. while True:
  317. if not pattern_lock.locked():
  318. # No pattern running, stop the task
  319. break
  320. status = get_status()
  321. disconnected = set()
  322. # Create a copy of the set for iteration
  323. active_connections = active_status_connections.copy()
  324. for websocket in active_connections:
  325. try:
  326. await websocket.send_json(status)
  327. except Exception:
  328. disconnected.add(websocket)
  329. # Clean up disconnected clients
  330. if disconnected:
  331. active_status_connections.difference_update(disconnected)
  332. # Wait before next update
  333. await asyncio.sleep(1)