cooptools 1.65__tar.gz → 1.67__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. {cooptools-1.65 → cooptools-1.67}/PKG-INFO +1 -1
  2. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/kinematic_utils.py +101 -28
  3. cooptools-1.67/cooptools/physics/orbit_fuzzer.py +151 -0
  4. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/waypointFollower.py +16 -7
  5. {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/PKG-INFO +1 -1
  6. {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/SOURCES.txt +1 -0
  7. {cooptools-1.65 → cooptools-1.67}/setup.py +1 -1
  8. {cooptools-1.65 → cooptools-1.67}/tests/test_kinematic.py +63 -0
  9. {cooptools-1.65 → cooptools-1.67}/tests/test_kinematic_utils.py +36 -4
  10. {cooptools-1.65 → cooptools-1.67}/tests/test_waypointFollower.py +117 -0
  11. {cooptools-1.65 → cooptools-1.67}/MANIFEST.in +0 -0
  12. {cooptools-1.65 → cooptools-1.67}/README.md +0 -0
  13. {cooptools-1.65 → cooptools-1.67}/cooptools/__init__.py +0 -0
  14. {cooptools-1.65 → cooptools-1.67}/cooptools/anchor.py +0 -0
  15. {cooptools-1.65 → cooptools-1.67}/cooptools/asyncable.py +0 -0
  16. {cooptools-1.65 → cooptools-1.67}/cooptools/cli/CliAtomicUserInteraction.py +0 -0
  17. {cooptools-1.65 → cooptools-1.67}/cooptools/cli/CliMenu.py +0 -0
  18. {cooptools-1.65 → cooptools-1.67}/cooptools/cli/__init__.py +0 -0
  19. {cooptools-1.65 → cooptools-1.67}/cooptools/cli/fileContentReturn.py +0 -0
  20. {cooptools-1.65 → cooptools-1.67}/cooptools/cnxn_info.py +0 -0
  21. {cooptools-1.65 → cooptools-1.67}/cooptools/colors.py +0 -0
  22. {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/__init__.py +0 -0
  23. {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/commandController.py +0 -0
  24. {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/commandProtocol.py +0 -0
  25. {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/exceptions.py +0 -0
  26. {cooptools-1.65 → cooptools-1.67}/cooptools/common.py +0 -0
  27. {cooptools-1.65 → cooptools-1.67}/cooptools/config.py +0 -0
  28. {cooptools-1.65 → cooptools-1.67}/cooptools/coopDataclass.py +0 -0
  29. {cooptools-1.65 → cooptools-1.67}/cooptools/coopEnum.py +0 -0
  30. {cooptools-1.65 → cooptools-1.67}/cooptools/coopthreading.py +0 -0
  31. {cooptools-1.65 → cooptools-1.67}/cooptools/currency.py +0 -0
  32. {cooptools-1.65 → cooptools-1.67}/cooptools/dataRefresher/__init__.py +0 -0
  33. {cooptools-1.65 → cooptools-1.67}/cooptools/dataRefresher/dataHub.py +0 -0
  34. {cooptools-1.65 → cooptools-1.67}/cooptools/dataRefresher/dataRefresher.py +0 -0
  35. {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/__init__.py +0 -0
  36. {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/dataProcessor.py +0 -0
  37. {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/dataStoreProtocol.py +0 -0
  38. {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/dbConnectionURI.py +0 -0
  39. {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/inMemoryDataStore.py +0 -0
  40. {cooptools-1.65 → cooptools-1.67}/cooptools/date_utils.py +0 -0
  41. {cooptools-1.65 → cooptools-1.67}/cooptools/decay.py +0 -0
  42. {cooptools-1.65 → cooptools-1.67}/cooptools/decor.py +0 -0
  43. {cooptools-1.65 → cooptools-1.67}/cooptools/dictPolicies.py +0 -0
  44. {cooptools-1.65 → cooptools-1.67}/cooptools/exceptions.py +0 -0
  45. {cooptools-1.65 → cooptools-1.67}/cooptools/expertise/__init__.py +0 -0
  46. {cooptools-1.65 → cooptools-1.67}/cooptools/expertise/expertiseArgs.py +0 -0
  47. {cooptools-1.65 → cooptools-1.67}/cooptools/expertise/expertiseSchedules.py +0 -0
  48. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/__init__.py +0 -0
  49. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/dicounted_cashflow_analysis.py +0 -0
  50. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/fund_projection.py +0 -0
  51. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/fund_projection_renderer.py +0 -0
  52. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/futureValueProjector.py +0 -0
  53. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/growth_projections.py +0 -0
  54. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/reports.py +0 -0
  55. {cooptools-1.65 → cooptools-1.67}/cooptools/finance/utils.py +0 -0
  56. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/__init__.py +0 -0
  57. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/circle_utils.py +0 -0
  58. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/common.py +0 -0
  59. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/curve_utils.py +0 -0
  60. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/line_utils.py +0 -0
  61. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/polygon_utils.py +0 -0
  62. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/rect_utils.py +0 -0
  63. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/triangle_utils.py +0 -0
  64. {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/vector_utils.py +0 -0
  65. {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/__init__.py +0 -0
  66. {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/astar_results_viewer.py +0 -0
  67. {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/draw.py +0 -0
  68. {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/graph.py +0 -0
  69. {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/graph_dcs.py +0 -0
  70. {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/graph_definitions.py +0 -0
  71. {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/utils.py +0 -0
  72. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/__init__.py +0 -0
  73. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/actionItemTracker/__init__.py +0 -0
  74. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/actionItemTracker/ai_tracker.py +0 -0
  75. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/actionItemTracker/dcs.py +0 -0
  76. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/discreteEventSimulator/__init__.py +0 -0
  77. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/discreteEventSimulator/discreteEventSimulator.py +0 -0
  78. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/jsonIO.py +0 -0
  79. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/metrics.py +0 -0
  80. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/triggerActionSystem/__init__.py +0 -0
  81. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/triggerActionSystem/signal.py +0 -0
  82. {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/triggerActionSystem/trigger.py +0 -0
  83. {cooptools-1.65 → cooptools-1.67}/cooptools/loggingHelpers.py +0 -0
  84. {cooptools-1.65 → cooptools-1.67}/cooptools/marchingSquares.py +0 -0
  85. {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/__init__.py +0 -0
  86. {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/cli.py +0 -0
  87. {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/dcs.py +0 -0
  88. {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/selectionCriteria.py +0 -0
  89. {cooptools-1.65 → cooptools-1.67}/cooptools/matrixManipulation.py +0 -0
  90. {cooptools-1.65 → cooptools-1.67}/cooptools/os_manip.py +0 -0
  91. {cooptools-1.65 → cooptools-1.67}/cooptools/pandasHelpers.py +0 -0
  92. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/__init__.py +0 -0
  93. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/demo/__init__.py +0 -0
  94. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/demo/server.py +0 -0
  95. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/kinematic.py +0 -0
  96. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/movement.py +0 -0
  97. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/trajectory.py +0 -0
  98. {cooptools-1.65 → cooptools-1.67}/cooptools/physics/waypoint.py +0 -0
  99. {cooptools-1.65 → cooptools-1.67}/cooptools/plotting.py +0 -0
  100. {cooptools-1.65 → cooptools-1.67}/cooptools/printing.py +0 -0
  101. {cooptools-1.65 → cooptools-1.67}/cooptools/protocols.py +0 -0
  102. {cooptools-1.65 → cooptools-1.67}/cooptools/qualifiers/__init__.py +0 -0
  103. {cooptools-1.65 → cooptools-1.67}/cooptools/qualifiers/cli.py +0 -0
  104. {cooptools-1.65 → cooptools-1.67}/cooptools/qualifiers/qualifier.py +0 -0
  105. {cooptools-1.65 → cooptools-1.67}/cooptools/randoms.py +0 -0
  106. {cooptools-1.65 → cooptools-1.67}/cooptools/register.py +0 -0
  107. {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/__init__.py +0 -0
  108. {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/dcs.py +0 -0
  109. {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/enums.py +0 -0
  110. {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/reservationmanager.py +0 -0
  111. {cooptools-1.65 → cooptools-1.67}/cooptools/retry.py +0 -0
  112. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/__init__.py +0 -0
  113. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/__init__.py +0 -0
  114. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/gridState.py +0 -0
  115. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/grid_base.py +0 -0
  116. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/hexGrid.py +0 -0
  117. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/rectGrid.py +0 -0
  118. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/hex_utils.py +0 -0
  119. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/sect_utils.py +0 -0
  120. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/sectorTree/__init__.py +0 -0
  121. {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/sectorTree/sectorTree.py +0 -0
  122. {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/__init__.py +0 -0
  123. {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/activityReport.py +0 -0
  124. {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/controlChart/__init__.py +0 -0
  125. {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/controlChart/controlChart.py +0 -0
  126. {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/controlChart/plotting.py +0 -0
  127. {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/oee/__init__.py +0 -0
  128. {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/oee/oeeHub.py +0 -0
  129. {cooptools-1.65 → cooptools-1.67}/cooptools/taskProcessing/__init__.py +0 -0
  130. {cooptools-1.65 → cooptools-1.67}/cooptools/taskProcessing/dcs.py +0 -0
  131. {cooptools-1.65 → cooptools-1.67}/cooptools/taskProcessing/taskProcessor.py +0 -0
  132. {cooptools-1.65 → cooptools-1.67}/cooptools/timeTracker/__init__.py +0 -0
  133. {cooptools-1.65 → cooptools-1.67}/cooptools/timeTracker/decay.py +0 -0
  134. {cooptools-1.65 → cooptools-1.67}/cooptools/timeTracker/timeTracker.py +0 -0
  135. {cooptools-1.65 → cooptools-1.67}/cooptools/timeWindow.py +0 -0
  136. {cooptools-1.65 → cooptools-1.67}/cooptools/toggles.py +0 -0
  137. {cooptools-1.65 → cooptools-1.67}/cooptools/transform.py +0 -0
  138. {cooptools-1.65 → cooptools-1.67}/cooptools/trends.py +0 -0
  139. {cooptools-1.65 → cooptools-1.67}/cooptools/typeProviders.py +0 -0
  140. {cooptools-1.65 → cooptools-1.67}/cooptools/typevalidation.py +0 -0
  141. {cooptools-1.65 → cooptools-1.67}/cooptools/version.py +0 -0
  142. {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/dependency_links.txt +0 -0
  143. {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/not-zip-safe +0 -0
  144. {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/requires.txt +0 -0
  145. {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/top_level.txt +0 -0
  146. {cooptools-1.65 → cooptools-1.67}/requirements.txt +0 -0
  147. {cooptools-1.65 → cooptools-1.67}/setup.cfg +0 -0
  148. {cooptools-1.65 → cooptools-1.67}/tests/test_CliAtomicUserInteraction.py +0 -0
  149. {cooptools-1.65 → cooptools-1.67}/tests/test_asyncable.py +0 -0
  150. {cooptools-1.65 → cooptools-1.67}/tests/test_cdp_bugs.py +0 -0
  151. {cooptools-1.65 → cooptools-1.67}/tests/test_commandDesignPattern.py +0 -0
  152. {cooptools-1.65 → cooptools-1.67}/tests/test_common.py +0 -0
  153. {cooptools-1.65 → cooptools-1.67}/tests/test_config.py +0 -0
  154. {cooptools-1.65 → cooptools-1.67}/tests/test_coopEnum.py +0 -0
  155. {cooptools-1.65 → cooptools-1.67}/tests/test_currency.py +0 -0
  156. {cooptools-1.65 → cooptools-1.67}/tests/test_date_utils.py +0 -0
  157. {cooptools-1.65 → cooptools-1.67}/tests/test_fund_projection.py +0 -0
  158. {cooptools-1.65 → cooptools-1.67}/tests/test_os_manip.py +0 -0
  159. {cooptools-1.65 → cooptools-1.67}/tests/test_pandas.py +0 -0
  160. {cooptools-1.65 → cooptools-1.67}/tests/test_printing.py +0 -0
  161. {cooptools-1.65 → cooptools-1.67}/tests/test_qualifiers.py +0 -0
  162. {cooptools-1.65 → cooptools-1.67}/tests/test_sectors.py +0 -0
  163. {cooptools-1.65 → cooptools-1.67}/tests/test_sectors_hex.py +0 -0
  164. {cooptools-1.65 → cooptools-1.67}/tests/test_sectors_sectorTree.py +0 -0
  165. {cooptools-1.65 → cooptools-1.67}/tests/test_statistics.py +0 -0
  166. {cooptools-1.65 → cooptools-1.67}/tests/test_tasks.py +0 -0
  167. {cooptools-1.65 → cooptools-1.67}/tests/test_toggles.py +0 -0
  168. {cooptools-1.65 → cooptools-1.67}/tests/test_trajectory.py +0 -0
  169. {cooptools-1.65 → cooptools-1.67}/tests/test_trends.py +0 -0
  170. {cooptools-1.65 → cooptools-1.67}/tests/test_typevalidation.py +0 -0
  171. {cooptools-1.65 → cooptools-1.67}/tests/test_vector_utils.py +0 -0
  172. {cooptools-1.65 → cooptools-1.67}/tests/test_version.py +0 -0
  173. {cooptools-1.65 → cooptools-1.67}/tests/tests_graph.py +0 -0
  174. {cooptools-1.65 → cooptools-1.67}/tests/tests_gridsystem.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cooptools
3
- Version: 1.65
3
+ Version: 1.67
4
4
  Summary: Generic Tooling
5
5
  Home-page: https://github.com/tylertjburns/cooptools
6
6
  Author: tburns
@@ -182,8 +182,16 @@ def _tick_accel_from_profile(profile: Tuple[PhaseResult, ...], dt: float) -> flo
182
182
  many phases fit within dt so the tick's velocity change matches the true profile
183
183
  instead of overshooting by committing the whole tick to one extreme.
184
184
  """
185
+ if dt < 0:
186
+ raise ValueError(f"_tick_accel_from_profile: dt must be >= 0, got {dt}")
185
187
  if not profile:
186
188
  return 0.0
189
+ if dt == 0:
190
+ # No time elapses this tick (e.g. delta_time_ms=0 for a position-only /
191
+ # realign update) -- the time-weighted average has no window to average
192
+ # over, so fall back to the instantaneous decision: the first phase's
193
+ # acceleration, which is what the weighted average converges to as dt -> 0+.
194
+ return profile[0].accel
187
195
  remaining = dt
188
196
  weighted = 0.0
189
197
  for phase in profile:
@@ -241,48 +249,113 @@ def _axis_arrival_accel(d: float, v: float, a: float, dt: float) -> float:
241
249
  return _tick_accel_from_profile(profile, dt)
242
250
 
243
251
 
252
+ def _settle_time(d: float, vs: float, a: float) -> float:
253
+ """Total time for single_axis_motion_profile(d, vs, ve=0, a) to settle at rest.
254
+ 0.0 for the trivial already-settled case (empty profile)."""
255
+ return sum(p.time for p in single_axis_motion_profile(d=d, vs=vs, ve=0.0, a=a))
256
+
257
+
258
+ def _solve_accel_for_target_settle_time(
259
+ d: float, vs: float, target_time: float, a_max: float,
260
+ max_iter: int = 20, tol_frac: float = 0.02,
261
+ ) -> float:
262
+ """Bisects for the accel magnitude a in (0, a_max] such that this axis's own
263
+ settle time (single_axis_motion_profile(d, vs, 0, a)'s total time) equals
264
+ target_time. _settle_time is monotonically decreasing in a (more accel -> less
265
+ time), so a solution in (0, a_max] always exists provided target_time >=
266
+ _settle_time(d, vs, a_max) (true by construction in arrival_acceleration: target
267
+ is the max settle time across axes at a_max, so every other axis's a_max-settle-
268
+ time is <= target). tol_frac is a fraction of a_max, not an absolute distance/time
269
+ tolerance, so it stays scale-appropriate across very different accel magnitudes.
270
+ """
271
+ lo, hi = 1e-6, a_max
272
+ tol = tol_frac * a_max
273
+ for _ in range(max_iter):
274
+ mid = (lo + hi) / 2.0
275
+ if _settle_time(d, vs, mid) > target_time:
276
+ lo = mid # too slow (this much accel takes longer than target) -- need more accel
277
+ else:
278
+ hi = mid
279
+ if hi - lo < tol:
280
+ break
281
+ return hi
282
+
283
+
244
284
  def arrival_acceleration(
245
285
  vector_to_goal: vec.FloatVec,
246
286
  velocity: vec.FloatVec,
247
287
  accel_magnitude: float,
248
288
  dt: float,
249
289
  ) -> vec.FloatVec:
250
- """Per-axis acceleration to arrive at the goal (rest, no overshoot).
251
-
252
- Each axis's accel budget is allocated proportional to its own share of the total
253
- remaining distance (accel_magnitude * |d_i| / |d|) -- not given the full budget
254
- independently and reconciled after the fact. This keeps every axis's own
255
- accelerate/decelerate profile synchronized: for two axes covering distances d_x, d_y
256
- with proportional budgets a*d_x/d, a*d_y/d, each axis's own symmetric time-optimal
257
- profile takes 2*sqrt(d_i/a_i) = 2*sqrt(d/a) to complete -- identical regardless of
258
- d_i, so both axes finish at the same time instead of one (having been given an equal
259
- or independently-negotiated share unrelated to how far it actually has to go)
260
- finishing and parking early while the other is still working through a much longer,
261
- independent overshoot-and-return of its own -- the previous design's real failure
262
- mode whenever the axes' distances were disproportionate.
263
-
264
- Per-axis budgets already sum, in a vector sense, to exactly accel_magnitude
265
- (sqrt(sum((a*|d_i|/d)^2)) = a), and _axis_arrival_accel never exceeds the budget
266
- it's given, so the combined result is guaranteed to stay within accel_magnitude --
267
- no further clamping needed.
290
+ """Per-axis acceleration to arrive at the goal (rest, no overshoot), synchronized
291
+ so every axis settles at the same time.
292
+
293
+ Each axis's OWN settle time if it alone had the full accel_magnitude is
294
+ _settle_time(d_i, v_i, accel_magnitude) -- this correctly incorporates both
295
+ distance AND velocity (an axis carrying a lot of unwanted velocity takes a long
296
+ time to settle even if its distance is small, so it is never mistakenly treated
297
+ as "almost done"). The slowest axis (largest natural settle time, call it T)
298
+ defines the pace: it gets the full accel_magnitude budget. Every other, faster
299
+ axis is deliberately throttled down to whatever smaller accel_i makes its OWN
300
+ settle time also equal T (via bisection against the same exact solver, since
301
+ settle time is a transcendental function of accel once vs != 0 -- no closed form
302
+ the way there is for the vs=0 case, where accel_i = accel_magnitude * d_i/d
303
+ happens to make 2*sqrt(d_i/accel_i) exactly constant across axes).
304
+
305
+ A prior design allocated budget via a static per-tick weighting PROXY (distance,
306
+ or distance-minus-velocity) instead of each axis's actual dynamics. Proxies have
307
+ two failure modes seen in practice: (1) weighting by raw distance alone starves
308
+ an axis that is positionally aligned but still carrying a lot of velocity --
309
+ this was a real production bug (unbounded orbiting around the goal); (2)
310
+ weighting by distance-minus-velocity can cancel to exactly zero for perfectly
311
+ ordinary motion (d_i numerically close to v_i), starving an axis and dividing by
312
+ zero downstream. Solving each axis's real settle time via the same exact
313
+ solver used for the actual per-tick control decision has neither failure mode --
314
+ an axis with small d_i and large v_i naturally has a LARGE settle time (it
315
+ genuinely takes a while to shed that velocity), so it is never starved; and nothing
316
+ here can cancel to exactly zero the way a linear proxy can.
317
+
318
+ The final per-axis accelerations (via _axis_arrival_accel, so the actual per-tick
319
+ command still benefits from the cheap-path/one-tick-lookahead/exact-profile-blend
320
+ machinery -- this synchronization only decides each axis's BUDGET, not its tick
321
+ command) are combined into one vector and rescaled to accel_magnitude only if
322
+ their combined length exceeds it -- a safety net, not the primary mechanism (by
323
+ construction by the time we get here, this should rarely trigger).
324
+
325
+ Cost note: this calls the exact single_axis_motion_profile solver on the order
326
+ of 2-3 times per non-dominant axis per tick (bisection), roughly 12-15x the cost
327
+ of the plain per-axis cheap-path check. This is deliberate: the alternative
328
+ (fast, proxy-based synchronization) has real correctness failure modes at
329
+ production velocity scales, including the orbiting bug this synchronization
330
+ exists to fix.
268
331
 
269
332
  This still does not fully solve simultaneous multi-axis arrival under one shared
270
333
  budget in general (a genuinely coupled constrained-control problem) -- known
271
- residual: very close to the goal, where |d| itself is near zero, can still settle to
272
- a small bounded oscillation rather than an exact stop; use a close/slow tolerance
273
- sized to your application rather than near-zero.
334
+ residual: very close to the goal, where every axis's own settle time is already
335
+ near zero, can still settle to a small bounded oscillation rather than an exact
336
+ stop; use a close/slow tolerance sized to your application rather than near-zero.
274
337
  """
275
- d_total = vec.vector_len(vector_to_goal)
276
- if math.isclose(d_total, 0, abs_tol=1e-9):
277
- # no distance left to proportion by -- just kill residual velocity per axis
278
- # with the full budget available
338
+ settle_times = [_settle_time(d, v, accel_magnitude) for d, v in zip(vector_to_goal, velocity)]
339
+ target_time = max(settle_times)
340
+
341
+ if math.isclose(target_time, 0, abs_tol=1e-9):
279
342
  return tuple(_axis_arrival_accel(d, v, accel_magnitude, dt)
280
343
  for d, v in zip(vector_to_goal, velocity))
281
344
 
282
- return tuple(
283
- _axis_arrival_accel(d, v, accel_magnitude * abs(d) / d_total, dt)
284
- for d, v in zip(vector_to_goal, velocity)
345
+ per_axis_budget = tuple(
346
+ accel_magnitude if math.isclose(t, target_time, abs_tol=1e-6)
347
+ else _solve_accel_for_target_settle_time(d, v, target_time, accel_magnitude)
348
+ for d, v, t in zip(vector_to_goal, velocity, settle_times)
349
+ )
350
+
351
+ per_axis_ideal = tuple(
352
+ _axis_arrival_accel(d, v, budget, dt)
353
+ for d, v, budget in zip(vector_to_goal, velocity, per_axis_budget)
285
354
  )
355
+ magnitude = vec.vector_len(per_axis_ideal)
356
+ if magnitude <= accel_magnitude:
357
+ return per_axis_ideal
358
+ return vec.scaled_to_length(per_axis_ideal, accel_magnitude)
286
359
 
287
360
 
288
361
  def scalar_lookahead_speed_accel(
@@ -0,0 +1,151 @@
1
+ """Long-running fuzz harness for WaypointFollower/GoalSeeker "gets stuck orbiting a
2
+ target instead of converging" bugs, under realistic, continually-rerouted usage.
3
+
4
+ Rather than guessing exact initial conditions, this runs a WaypointFollower that
5
+ behaves like a real game agent: continually injects fresh chains of 3-10 waypoints
6
+ once it reaches a destination, and (at REROUTE_PROB_PER_TICK) intermittently replaces
7
+ its remaining queue entirely before reaching the destination -- a "reroute", which
8
+ leaves whatever velocity it was carrying suddenly pointed at a new, unrelated goal.
9
+ A per-destination watchdog flags a run as "stuck" if a destination takes far longer
10
+ than physically necessary to reach, and dumps a full state snapshot at that moment so
11
+ the exact scenario can be replayed deterministically in a unit test.
12
+
13
+ This harness found two real, since-fixed/documented bugs (see physics/CLAUDE.md
14
+ gotchas #2 and #5, and tests.test_waypointFollower.Test_WaypointFollower_OrbitingRisk):
15
+ - WaypointFollower._cascade_realign could hand off a velocity pointed ~148 degrees
16
+ away from the actual next waypoint (fixed).
17
+ - The seek/lookahead branch of GoalSeeker.update() (continue_after_goal_reached=True)
18
+ applies acceleration always pointed at a fixed target with no velocity damping
19
+ whenever there's still "plenty of room" to the eventual destination -- still open.
20
+
21
+ Deterministic via a fixed RNG seed -- rerun with the same seed to reproduce a find.
22
+ Run with: python -m cooptools.physics.orbit_fuzzer [--seed N] [--reroute-prob P]
23
+ """
24
+ import argparse
25
+ import math
26
+ import random
27
+
28
+ from cooptools.physics.waypointFollower import WaypointFollower
29
+ from cooptools.physics.waypoint import Waypoint
30
+ from cooptools.transform import Transform
31
+
32
+ DT_MS = 16
33
+ MAX_ACCELERATION = 140.0
34
+ AREA_MIN, AREA_MAX = 0.0, 1000.0
35
+ CLOSE_TOLERANCE = 2.0
36
+ SLOW_TOLERANCE = 2.0
37
+ REALIGN = True
38
+
39
+ MIN_CHAIN, MAX_CHAIN = 3, 10
40
+
41
+ # Watchdog: a destination should never realistically take longer than this many ticks.
42
+ # Sized generously (10x a rough worst-case direct-line time across the whole area at
43
+ # this accel) so only genuinely pathological (stuck/orbiting) runs trip it.
44
+ WORST_CASE_DIRECT_TICKS = int(2 * math.sqrt((AREA_MAX - AREA_MIN) * math.sqrt(2) / MAX_ACCELERATION) * 1000 / DT_MS)
45
+ STUCK_TICK_THRESHOLD = 10 * WORST_CASE_DIRECT_TICKS
46
+
47
+
48
+ def random_point(rng: random.Random):
49
+ return (rng.uniform(AREA_MIN, AREA_MAX), rng.uniform(AREA_MIN, AREA_MAX))
50
+
51
+
52
+ def make_chain(rng: random.Random):
53
+ n = rng.randint(MIN_CHAIN, MAX_CHAIN)
54
+ return [Waypoint(end_pos=random_point(rng), is_destination=(i == n - 1)) for i in range(n)]
55
+
56
+
57
+ def snapshot(follower: WaypointFollower, seed: int, total_ticks: int, ticks_since_target_set: int,
58
+ destinations_reached: int, reroutes: int):
59
+ print("=" * 70)
60
+ print("STUCK CONDITION DETECTED")
61
+ print("=" * 70)
62
+ print(f"seed={seed}")
63
+ print(f"total_ticks={total_ticks} (~{total_ticks * DT_MS / 1000 / 60:.1f} min sim time)")
64
+ print(f"ticks_since_current_target_set={ticks_since_target_set} "
65
+ f"(~{ticks_since_target_set * DT_MS / 1000 / 60:.1f} min)")
66
+ print(f"destinations_reached={destinations_reached} reroutes={reroutes}")
67
+ print(f"Position={follower.Position}")
68
+ print(f"Velocity={follower.Velocity.CurrentVelocity_M_S}")
69
+ print(f"Acceleration={follower.Acceleration.CurrentAccel_M_S}")
70
+ print(f"CurrentTarget={follower.CurrentTarget}")
71
+ print(f"remaining waypoints ({len(follower.waypoints)}):")
72
+ for wp in follower.waypoints:
73
+ print(f" {wp}")
74
+ dist_to_target = math.hypot(
75
+ follower.Position[0] - follower.CurrentTarget.end_pos[0],
76
+ follower.Position[1] - follower.CurrentTarget.end_pos[1],
77
+ )
78
+ print(f"distance_to_current_target={dist_to_target:.3f}")
79
+ print(f"config: max_acceleration={MAX_ACCELERATION} close_tol={CLOSE_TOLERANCE} "
80
+ f"slow_tol={SLOW_TOLERANCE} realign={REALIGN} dt_ms={DT_MS}")
81
+ print("=" * 70)
82
+
83
+
84
+ def run(seed: int, reroute_prob_per_tick: float, total_tick_budget: int):
85
+ rng = random.Random(seed)
86
+ start = random_point(rng)
87
+ follower = WaypointFollower(
88
+ name='fuzzer',
89
+ initial_transform=Transform(translation=start),
90
+ max_acceleration=MAX_ACCELERATION,
91
+ )
92
+ follower.add_waypoints(make_chain(rng))
93
+
94
+ total_ticks = 0
95
+ ticks_since_target_set = 0
96
+ destinations_reached = 0
97
+ reroutes = 0
98
+
99
+ while total_ticks < total_tick_budget:
100
+ # intermittent disruption: replace the whole remaining queue before it's
101
+ # consumed, simulating a reroute mid-flight
102
+ if follower.waypoints and rng.random() < reroute_prob_per_tick:
103
+ follower.waypoints = []
104
+ follower.add_waypoints(make_chain(rng))
105
+ ticks_since_target_set = 0
106
+ reroutes += 1
107
+
108
+ reached = follower.update(
109
+ delta_time_ms=DT_MS,
110
+ close_enough_tolerance=CLOSE_TOLERANCE,
111
+ slow_enough_tolerance=SLOW_TOLERANCE,
112
+ realign_to_next_waypoint=REALIGN,
113
+ )
114
+
115
+ total_ticks += 1
116
+ ticks_since_target_set += 1
117
+
118
+ for wp in reached:
119
+ ticks_since_target_set = 0
120
+ if wp.is_destination:
121
+ destinations_reached += 1
122
+
123
+ if not follower.waypoints:
124
+ follower.add_waypoints(make_chain(rng))
125
+ ticks_since_target_set = 0
126
+
127
+ if ticks_since_target_set >= STUCK_TICK_THRESHOLD:
128
+ snapshot(follower, seed, total_ticks, ticks_since_target_set, destinations_reached, reroutes)
129
+ return
130
+
131
+ if total_ticks % 200_000 == 0:
132
+ print(f"... {total_ticks} ticks (~{total_ticks * DT_MS / 1000 / 60:.1f} min), "
133
+ f"{destinations_reached} destinations reached, {reroutes} reroutes, "
134
+ f"still going (ticks_since_target_set={ticks_since_target_set})")
135
+
136
+ print(f"no stuck condition found within {total_tick_budget} ticks")
137
+
138
+
139
+ def main():
140
+ parser = argparse.ArgumentParser(description=__doc__)
141
+ parser.add_argument('--seed', type=int, default=1)
142
+ parser.add_argument('--reroute-prob', type=float, default=0.0,
143
+ help='per-tick probability of a mid-flight reroute (0 disables)')
144
+ parser.add_argument('--budget', type=int, default=20_000_000,
145
+ help='total tick safety cap so the script always terminates')
146
+ args = parser.parse_args()
147
+ run(seed=args.seed, reroute_prob_per_tick=args.reroute_prob, total_tick_budget=args.budget)
148
+
149
+
150
+ if __name__ == '__main__':
151
+ main()
@@ -169,12 +169,21 @@ class WaypointFollower:
169
169
  remaining_overshoot = vec.distance_between(anchor, self._goal_seeker.Position)
170
170
  last_direction = None
171
171
 
172
- # A tiny fixed epsilon, not close_enough_tolerance -- this only guards against
173
- # floating-point noise (exactly zero overshoot). Using the caller's arrival
174
- # tolerance here instead would silently skip realigning any real overshoot
175
- # smaller than that tolerance (e.g. a 2px arrival tolerance would disable
176
- # redirecting a legitimate 0.5px overshoot entirely).
177
- while remaining_overshoot > 1e-9 and len(self.waypoints) > 0:
172
+ # Loop on len(self.waypoints) alone, NOT also remaining_overshoot > 0 -- a
173
+ # waypoint can be consumed via the close_enough_tolerance slack below even when
174
+ # the actual overshoot is slightly LESS than the segment length, which drives
175
+ # remaining_overshoot negative. Exiting the loop right there (as an earlier
176
+ # version did) freezes last_direction at the heading of the segment just
177
+ # consumed -- pointing AT the waypoint just reached, not toward whatever comes
178
+ # after it -- and hands that stale, often wildly wrong heading to
179
+ # set_velocity below (confirmed regression: a real cascade redirected velocity
180
+ # ~148 degrees away from the actual next waypoint, feeding directly into the
181
+ # unbounded-orbiting bug). Continuing the loop (with overshoot clamped to a
182
+ # non-negative floor) lets it recompute direction toward the NEW anchor's next
183
+ # waypoint; since a clamped-to-zero remaining_overshoot can essentially never
184
+ # cover another real segment, the very next iteration's threshold check settles
185
+ # partway (zero distance) along that newly-correct direction instead.
186
+ while len(self.waypoints) > 0:
178
187
  next_wp = self.waypoints[0]
179
188
  direction = vec.unit_vector(vec.vector_between(anchor, next_wp.end_pos))
180
189
 
@@ -195,7 +204,7 @@ class WaypointFollower:
195
204
 
196
205
  logger.info(f"\'{self.Name}\' overshot into waypoint \'{self._goal_seeker.Name}\' "
197
206
  f"at {tuple(round(x, 3) for x in next_wp.end_pos)} while realigning")
198
- remaining_overshoot -= seg_len
207
+ remaining_overshoot = max(0.0, remaining_overshoot - seg_len)
199
208
  anchor = next_wp.end_pos
200
209
  popped = self.waypoints.pop(0)
201
210
  self._last_reached_waypoint = popped
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cooptools
3
- Version: 1.65
3
+ Version: 1.67
4
4
  Summary: Generic Tooling
5
5
  Home-page: https://github.com/tylertjburns/cooptools
6
6
  Author: tburns
@@ -104,6 +104,7 @@ cooptools/physics/__init__.py
104
104
  cooptools/physics/kinematic.py
105
105
  cooptools/physics/kinematic_utils.py
106
106
  cooptools/physics/movement.py
107
+ cooptools/physics/orbit_fuzzer.py
107
108
  cooptools/physics/trajectory.py
108
109
  cooptools/physics/waypoint.py
109
110
  cooptools/physics/waypointFollower.py
@@ -7,7 +7,7 @@ with open('requirements.txt') as f:
7
7
  requirements = f.read().splitlines()
8
8
 
9
9
  setuptools.setup(name='cooptools',
10
- version='1.65',
10
+ version='1.67',
11
11
  description='Generic Tooling',
12
12
  url='https://github.com/tylertjburns/cooptools',
13
13
  author='tburns',
@@ -1,3 +1,4 @@
1
+ import math
1
2
  import unittest
2
3
 
3
4
  import cooptools.geometry_utils.vector_utils as vec
@@ -170,5 +171,67 @@ class Test_GoalSeeker_ArrivalWithLooseTolerance(unittest.TestCase):
170
171
  self.assertLess(max_single_tick_drop, 10.0)
171
172
 
172
173
 
174
+ class Test_GoalSeeker_OrbitingRisk(unittest.TestCase):
175
+ """Reported bug: a mover with velocity purely tangential to (perpendicular to) the
176
+ vector-to-goal can settle into a slow, near-circular decaying orbit instead of
177
+ converging in a time comparable to a direct approach.
178
+
179
+ Initial conditions follow the classic circular-orbit balance for a CONSTANT-
180
+ magnitude central acceleration (unlike real gravity's inverse-square law): the
181
+ speed required for a circular orbit of radius r under constant inward acceleration
182
+ a is v = sqrt(a*r), from equating centripetal acceleration a_c = v^2/r to a. For
183
+ goal=(0,0), init_pos=(10,0), max_acceleration=1: v = sqrt(1*10) = sqrt(10) ~= 3.162.
184
+
185
+ This is not a contrived edge case: sqrt(a*r) is also exactly the peak speed a
186
+ direct one-axis bang-bang deceleration over the same distance would reach (v_peak =
187
+ sqrt(a*d)) -- same kinetic-energy scale as an ordinary approach, just pointed
188
+ entirely the wrong way.
189
+
190
+ Suspected root cause: kinematic_utils.arrival_acceleration solves each axis
191
+ independently with a budget proportional to |d_i|/|d| (physics/CLAUDE.md gotcha
192
+ #2), which is not a true centripetal correction -- confirmed empirically: with
193
+ these exact initial conditions the mover holds within ~2% of its initial radius
194
+ for roughly the first 300 ticks, then decays into the goal only after ~30 real-time
195
+ seconds (measured stable across dt from 1ms to 100ms, so this is a genuine
196
+ continuous-time property of the control law, not a tick-size artifact) -- about
197
+ 4.7x longer than the ~6.3s a direct radial deceleration over the same distance
198
+ would take.
199
+
200
+ Named test_orbiting_risk_converges as a known-failure regression: it currently
201
+ FAILS (documenting the bug) and should PASS once the arrival controller converges
202
+ from tangential velocity in a time comparable to a direct approach."""
203
+
204
+ def test_orbiting_risk_converges(self):
205
+ # arrange -- goal at the origin, mover 10 units out with a purely tangential
206
+ # velocity at the circular-orbit resonance speed for this radius/accel (see
207
+ # class docstring for the derivation).
208
+ seeker = _make_seeker(start=(10.0, 0.0), max_acceleration=1.0)
209
+ seeker.set_velocity((0.0, math.sqrt(10.0)))
210
+ goal = (0.0, 0.0)
211
+
212
+ # a direct one-axis bang-bang deceleration over the same distance (d=10, a=1)
213
+ # takes 2*sqrt(d/a) seconds; a healthy arrival controller should converge from
214
+ # an equal-energy tangential kick in not too much more time than that -- 3x is
215
+ # a generous margin, not a tight bound. (Actual observed behavior: ~4.7x, i.e.
216
+ # this margin is not met today.)
217
+ direct_optimal_time_s = 2 * math.sqrt(10.0 / 1.0)
218
+ delta_time_ms = 16
219
+ max_acceptable_steps = int(3 * direct_optimal_time_s * 1000 / delta_time_ms)
220
+
221
+ reached = False
222
+ steps = 0
223
+ while not reached and steps < max_acceptable_steps:
224
+ reached = seeker.update(delta_time_ms=delta_time_ms, goal_pos=goal)
225
+ steps += 1
226
+
227
+ self.assertTrue(
228
+ reached,
229
+ f'seeker failed to converge within {max_acceptable_steps} steps (3x the '
230
+ f'direct-approach optimal time of {direct_optimal_time_s:.2f}s) -- it is '
231
+ f'likely stuck in a slow decaying orbit around the goal instead of '
232
+ f'converging directly'
233
+ )
234
+
235
+
173
236
  if __name__ == '__main__':
174
237
  unittest.main()
@@ -71,6 +71,18 @@ class Test_AxisArrivalAccel(unittest.TestCase):
71
71
  self.assertNotEqual(result, a) # not the naive "just keep accelerating" answer
72
72
  self.assertLess(result, a)
73
73
 
74
+ def test__dt_zero__in_braking_regime__does_not_raise_and_returns_brake(self):
75
+ # regression: a position-only update (delta_time_ms=0, e.g. a waypoint
76
+ # realign) while already past the switching threshold used to fall through
77
+ # to single_axis_motion_profile + _tick_accel_from_profile(profile, dt=0),
78
+ # which raised ZeroDivisionError. d=10, v=20, a=5 is the same past-threshold
79
+ # state as test__well_past_stopping_distance__brakes above.
80
+ result = kin._axis_arrival_accel(d=10, v=20, a=5, dt=0)
81
+ self.assertEqual(result, -5.0)
82
+
83
+ def test__dt_zero__in_cheap_accelerate_regime__still_accelerates(self):
84
+ self.assertEqual(kin._axis_arrival_accel(d=1000, v=1, a=5, dt=0), 5.0)
85
+
74
86
 
75
87
  class Test_TickAccelFromProfile(unittest.TestCase):
76
88
  """Direct unit coverage for the tick-blending helper in isolation, using
@@ -100,6 +112,21 @@ class Test_TickAccelFromProfile(unittest.TestCase):
100
112
  result = kin._tick_accel_from_profile((p1,), dt=0.03)
101
113
  self.assertAlmostEqual(result, (-8.0 * 0.01) / 0.03, places=9)
102
114
 
115
+ def test__dt_zero__non_empty_profile__returns_first_phase_accel_without_dividing_by_zero(self):
116
+ # regression: dt=0 (e.g. a position-only / realign update with
117
+ # delta_time_ms=0) used to divide weighted-by-zero into a ZeroDivisionError.
118
+ p1 = kin.PhaseResult(length=0, time=0.01, label="brake", accel=-8.0)
119
+ p2 = kin.PhaseResult(length=0, time=5.0, label="accel", accel=3.0)
120
+ self.assertEqual(kin._tick_accel_from_profile((p1, p2), dt=0), -8.0)
121
+
122
+ def test__dt_zero__empty_profile__returns_zero(self):
123
+ self.assertEqual(kin._tick_accel_from_profile((), dt=0), 0.0)
124
+
125
+ def test__dt_negative__raises_value_error(self):
126
+ p1 = kin.PhaseResult(length=0, time=0.01, label="brake", accel=-8.0)
127
+ with self.assertRaises(ValueError):
128
+ kin._tick_accel_from_profile((p1,), dt=-0.01)
129
+
103
130
 
104
131
  class Test_ArrivalAcceleration(unittest.TestCase):
105
132
  """Direct unit coverage for the multi-axis wrapper around _axis_arrival_accel."""
@@ -142,16 +169,21 @@ class Test_ArrivalAcceleration(unittest.TestCase):
142
169
  self.assertAlmostEqual(result[0], result[1], places=6)
143
170
 
144
171
  def test__budget_allocated_proportional_to_each_axis_share_of_total_distance(self):
145
- # d=(100, 25) -> axis 0 should get 4x axis 1's budget (100/25), matching each
146
- # axis's share of the resultant direction, not an equal or independently
147
- # negotiated split
172
+ # d=(100, 25), starting from rest -- axis 0 should get ~4x axis 1's budget
173
+ # (100/25), matching each axis's share of the resultant direction, not an
174
+ # equal or independently negotiated split. For the vs=0 case this ratio has
175
+ # a closed form (budget_i = accel_magnitude * d_i/d exactly equalizes each
176
+ # axis's own 2*sqrt(d_i/a_i) settle time), which the settle-time-sync design
177
+ # now reaches via bisection rather than that closed form directly -- places=2
178
+ # reflects the bisection's tolerance, not a loss of correctness (see
179
+ # _solve_accel_for_target_settle_time's tol_frac).
148
180
  result = kin.arrival_acceleration(
149
181
  vector_to_goal=(100, 25),
150
182
  velocity=(0, 0),
151
183
  accel_magnitude=5,
152
184
  dt=DT,
153
185
  )
154
- self.assertAlmostEqual(result[0] / result[1], 4.0, places=6)
186
+ self.assertAlmostEqual(result[0] / result[1], 4.0, places=2)
155
187
 
156
188
  def test__disproportionate_axis_distances__still_synchronizes_arrival_time(self):
157
189
  # Regression test: a real case (traced from the demo) had one axis complete its