pattern_manager.py 13 KB

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