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.
- {cooptools-1.65 → cooptools-1.67}/PKG-INFO +1 -1
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/kinematic_utils.py +101 -28
- cooptools-1.67/cooptools/physics/orbit_fuzzer.py +151 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/waypointFollower.py +16 -7
- {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/PKG-INFO +1 -1
- {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/SOURCES.txt +1 -0
- {cooptools-1.65 → cooptools-1.67}/setup.py +1 -1
- {cooptools-1.65 → cooptools-1.67}/tests/test_kinematic.py +63 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_kinematic_utils.py +36 -4
- {cooptools-1.65 → cooptools-1.67}/tests/test_waypointFollower.py +117 -0
- {cooptools-1.65 → cooptools-1.67}/MANIFEST.in +0 -0
- {cooptools-1.65 → cooptools-1.67}/README.md +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/anchor.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/asyncable.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/cli/CliAtomicUserInteraction.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/cli/CliMenu.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/cli/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/cli/fileContentReturn.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/cnxn_info.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/colors.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/commandController.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/commandProtocol.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/commandDesignPattern/exceptions.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/common.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/config.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/coopDataclass.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/coopEnum.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/coopthreading.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/currency.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataRefresher/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataRefresher/dataHub.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataRefresher/dataRefresher.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/dataProcessor.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/dataStoreProtocol.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/dbConnectionURI.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dataStore/inMemoryDataStore.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/date_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/decay.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/decor.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/dictPolicies.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/exceptions.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/expertise/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/expertise/expertiseArgs.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/expertise/expertiseSchedules.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/dicounted_cashflow_analysis.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/fund_projection.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/fund_projection_renderer.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/futureValueProjector.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/growth_projections.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/reports.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/finance/utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/circle_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/common.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/curve_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/line_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/polygon_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/rect_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/triangle_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/geometry_utils/vector_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/astar_results_viewer.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/draw.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/graph.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/graph_dcs.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/graph_definitions.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/graphs/utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/actionItemTracker/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/actionItemTracker/ai_tracker.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/actionItemTracker/dcs.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/discreteEventSimulator/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/discreteEventSimulator/discreteEventSimulator.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/jsonIO.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/metrics.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/triggerActionSystem/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/triggerActionSystem/signal.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/ideas/triggerActionSystem/trigger.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/loggingHelpers.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/marchingSquares.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/cli.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/dcs.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/materialHandling/selectionCriteria.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/matrixManipulation.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/os_manip.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/pandasHelpers.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/demo/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/demo/server.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/kinematic.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/movement.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/trajectory.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/physics/waypoint.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/plotting.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/printing.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/protocols.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/qualifiers/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/qualifiers/cli.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/qualifiers/qualifier.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/randoms.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/register.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/dcs.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/enums.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/reservation/reservationmanager.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/retry.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/gridState.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/grid_base.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/hexGrid.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/grids/rectGrid.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/hex_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/sect_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/sectorTree/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/sectors/sectorTree/sectorTree.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/activityReport.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/controlChart/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/controlChart/controlChart.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/controlChart/plotting.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/oee/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/statistics/oee/oeeHub.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/taskProcessing/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/taskProcessing/dcs.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/taskProcessing/taskProcessor.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/timeTracker/__init__.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/timeTracker/decay.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/timeTracker/timeTracker.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/timeWindow.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/toggles.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/transform.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/trends.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/typeProviders.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/typevalidation.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools/version.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/dependency_links.txt +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/not-zip-safe +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/requires.txt +0 -0
- {cooptools-1.65 → cooptools-1.67}/cooptools.egg-info/top_level.txt +0 -0
- {cooptools-1.65 → cooptools-1.67}/requirements.txt +0 -0
- {cooptools-1.65 → cooptools-1.67}/setup.cfg +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_CliAtomicUserInteraction.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_asyncable.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_cdp_bugs.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_commandDesignPattern.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_common.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_config.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_coopEnum.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_currency.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_date_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_fund_projection.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_os_manip.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_pandas.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_printing.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_qualifiers.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_sectors.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_sectors_hex.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_sectors_sectorTree.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_statistics.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_tasks.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_toggles.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_trajectory.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_trends.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_typevalidation.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_vector_utils.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/test_version.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/tests_graph.py +0 -0
- {cooptools-1.65 → cooptools-1.67}/tests/tests_gridsystem.py +0 -0
|
@@ -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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
272
|
-
a small bounded oscillation rather than an exact
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
|
|
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
|
|
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
|
|
@@ -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
|
|
@@ -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)
|
|
146
|
-
# axis's share of the resultant direction, not an
|
|
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=
|
|
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
|