sci-solver 0.1.0__cp313-cp313-win_amd64.whl
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.
- sci_solver/__init__.py +9 -0
- sci_solver/api.py +76 -0
- sci_solver/cli/__init__.py +1 -0
- sci_solver/cli/vector_sci.py +142 -0
- sci_solver/config.py +138 -0
- sci_solver/evaluation.py +22 -0
- sci_solver/solvers/__init__.py +4 -0
- sci_solver/solvers/_cubic_minima.c +177 -0
- sci_solver/solvers/_cubic_minima.cp313-win_amd64.pyd +0 -0
- sci_solver/solvers/base.py +95 -0
- sci_solver/solvers/collision_search.py +66 -0
- sci_solver/solvers/contact_solver.py +583 -0
- sci_solver/solvers/cubic_utils.py +128 -0
- sci_solver/solvers/matching_numerics.py +170 -0
- sci_solver/solvers/reaction_matching.py +127 -0
- sci_solver/solvers/vector_matching.py +214 -0
- sci_solver-0.1.0.dist-info/METADATA +141 -0
- sci_solver-0.1.0.dist-info/RECORD +23 -0
- sci_solver-0.1.0.dist-info/WHEEL +5 -0
- sci_solver-0.1.0.dist-info/entry_points.txt +2 -0
- sci_solver-0.1.0.dist-info/licenses/LICENSE +202 -0
- sci_solver-0.1.0.dist-info/licenses/NOTICE +10 -0
- sci_solver-0.1.0.dist-info/top_level.txt +1 -0
sci_solver/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""SCI: Sequential Contact Insertion for continuous-time robot fleet planning."""
|
|
2
|
+
from .api import Solution, solve
|
|
3
|
+
from .config import Config, ProblemConfig, SolverConfig
|
|
4
|
+
from .solvers import ContactSolver, RelativeTrajectory
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Config", "ProblemConfig", "SolverConfig", "ContactSolver",
|
|
8
|
+
"RelativeTrajectory", "Solution", "solve",
|
|
9
|
+
]
|
sci_solver/api.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Convenience interface around the reference joint solver."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .config import Config, ProblemConfig, SolverConfig
|
|
7
|
+
from .evaluation import continuous_acceleration_cost
|
|
8
|
+
from .solvers.contact_solver import ContactSolver, RelativeTrajectory
|
|
9
|
+
from .solvers.vector_matching import VectorContact
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Solution:
|
|
14
|
+
"""A returned iterate and its diagnostics; inspect success before using it.
|
|
15
|
+
|
|
16
|
+
The trajectory remains available when solving fails, for diagnosis.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
trajectory: RelativeTrajectory
|
|
20
|
+
contacts: tuple[VectorContact, ...]
|
|
21
|
+
metrics: dict
|
|
22
|
+
acceleration_cost: float
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def success(self) -> bool:
|
|
26
|
+
return bool(self.metrics["converged"])
|
|
27
|
+
|
|
28
|
+
def sample(self, times, derivative: int = 0) -> np.ndarray:
|
|
29
|
+
"""Evaluate position or derivative 1, 2, or 3; shape (robots, times, dimension).
|
|
30
|
+
|
|
31
|
+
Times must be a finite one-dimensional array within the planning horizon.
|
|
32
|
+
At an interior contact the third derivative uses the segment on its right.
|
|
33
|
+
"""
|
|
34
|
+
return self.trajectory.robots(times, derivative)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def solve(
|
|
38
|
+
initial_positions,
|
|
39
|
+
final_positions,
|
|
40
|
+
*,
|
|
41
|
+
time_horizon: float,
|
|
42
|
+
min_distance: float,
|
|
43
|
+
initial_velocities=None,
|
|
44
|
+
final_velocities=None,
|
|
45
|
+
options: SolverConfig | None = None,
|
|
46
|
+
) -> Solution:
|
|
47
|
+
"""Plan for N >= 2 robots in d >= 2 dimensions with prescribed endpoints.
|
|
48
|
+
|
|
49
|
+
Positions and optional velocities have shape (N, d). Omitted velocities
|
|
50
|
+
are zero. Use SolverConfig(sci_detection_tol=...) to set the absolute
|
|
51
|
+
separation allowance explicitly; omitted options retain the reference
|
|
52
|
+
solver's defaults. This local solver can return an unsuccessful iterate.
|
|
53
|
+
"""
|
|
54
|
+
initial_positions = np.asarray(initial_positions, dtype=float)
|
|
55
|
+
final_positions = np.asarray(final_positions, dtype=float)
|
|
56
|
+
if initial_positions.ndim != 2 or final_positions.shape != initial_positions.shape:
|
|
57
|
+
raise ValueError("Endpoint positions must have matching shape (N, d)")
|
|
58
|
+
n, d = initial_positions.shape
|
|
59
|
+
if time_horizon is None:
|
|
60
|
+
raise ValueError("time_horizon must be positive and finite")
|
|
61
|
+
config = Config(
|
|
62
|
+
problem=ProblemConfig(
|
|
63
|
+
n_robots=n, dimension=d, time_horizon=time_horizon, min_distance=min_distance,
|
|
64
|
+
),
|
|
65
|
+
solver=SolverConfig() if options is None else options,
|
|
66
|
+
)
|
|
67
|
+
solver = ContactSolver(config)
|
|
68
|
+
solver.set_initial_states(initial_positions, initial_velocities)
|
|
69
|
+
solver.set_final_states(final_positions, final_velocities)
|
|
70
|
+
result = solver.generate_trajectories()
|
|
71
|
+
return Solution(
|
|
72
|
+
trajectory=solver.trajectory,
|
|
73
|
+
contacts=tuple(solver.contacts),
|
|
74
|
+
metrics=result["metrics"],
|
|
75
|
+
acceleration_cost=continuous_acceleration_cost(solver),
|
|
76
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line interface for saved endpoint problems."""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Run explicit-vector SCI on NPZ endpoint data, or a bounded 20-case smoke suite."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from itertools import combinations
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from sci_solver import Config, ContactSolver
|
|
15
|
+
from sci_solver.config import ProblemConfig, SolverConfig
|
|
16
|
+
from sci_solver.evaluation import continuous_acceleration_cost
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def smoke_endpoints(d, case):
|
|
20
|
+
"""Small deterministic cases; the fleet extends the manuscript's seed 82."""
|
|
21
|
+
if case in ("fleet", "fleet5"):
|
|
22
|
+
n = 5 if case == "fleet5" else 3
|
|
23
|
+
rng = np.random.default_rng(0 if n == 5 else 82)
|
|
24
|
+
|
|
25
|
+
def draw():
|
|
26
|
+
while True:
|
|
27
|
+
points = rng.uniform(-4, 4, (n, 2))
|
|
28
|
+
if all(
|
|
29
|
+
np.linalg.norm(points[p] - points[q]) > 1.5
|
|
30
|
+
for p, q in combinations(range(n), 2)
|
|
31
|
+
):
|
|
32
|
+
return points
|
|
33
|
+
|
|
34
|
+
start, goal = draw(), draw()
|
|
35
|
+
rng = np.random.default_rng(17)
|
|
36
|
+
start = np.column_stack((start, rng.normal(scale=0.1, size=(n, d - 2))))
|
|
37
|
+
goal = np.column_stack((goal, rng.normal(scale=0.1, size=(n, d - 2))))
|
|
38
|
+
return start, goal
|
|
39
|
+
start = np.zeros((2, d))
|
|
40
|
+
if case == "free":
|
|
41
|
+
start[1, 0] = 3
|
|
42
|
+
return start, start + 1
|
|
43
|
+
start[:, :2] = [[-2, -0.2], [2, 0.2]]
|
|
44
|
+
if case == "head_on":
|
|
45
|
+
start[:, 1] = 0
|
|
46
|
+
goal = start.copy()
|
|
47
|
+
goal[:, 0] *= -1
|
|
48
|
+
return start, goal
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def smoke_report():
|
|
52
|
+
records = []
|
|
53
|
+
for d in (2, 3, 5, 10):
|
|
54
|
+
for case in ("free", "head_on", "offset", "fleet", "fleet5"):
|
|
55
|
+
start, goal = smoke_endpoints(d, case)
|
|
56
|
+
cfg = Config(
|
|
57
|
+
name=f"smoke_{case}_{d}",
|
|
58
|
+
problem=ProblemConfig(
|
|
59
|
+
n_robots=len(start), dimension=d, time_horizon=6, timestep=0.1, min_distance=1
|
|
60
|
+
),
|
|
61
|
+
solver=SolverConfig(
|
|
62
|
+
sci_detection_tol=1e-6,
|
|
63
|
+
use_analytical_jacobian=True,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
solver = ContactSolver(cfg)
|
|
67
|
+
solver.set_initial_states(start)
|
|
68
|
+
solver.set_final_states(goal)
|
|
69
|
+
metrics = solver.generate_trajectories()["metrics"]
|
|
70
|
+
record = dict(case=case, dimension=d, n_robots=len(start), vector=metrics)
|
|
71
|
+
record["vector_acceleration_cost"] = continuous_acceleration_cost(solver)
|
|
72
|
+
records.append(record)
|
|
73
|
+
return dict(
|
|
74
|
+
environment=dict(
|
|
75
|
+
python=sys.version,
|
|
76
|
+
platform=platform.platform(),
|
|
77
|
+
packages={name: version(name) for name in ("numpy", "scipy")},
|
|
78
|
+
thread_limits={
|
|
79
|
+
name: os.environ.get(name)
|
|
80
|
+
for name in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS", "VECLIB_MAXIMUM_THREADS")
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
records=records,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv=None):
|
|
89
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
90
|
+
source = parser.add_mutually_exclusive_group(required=True)
|
|
91
|
+
source.add_argument("--smoke", action="store_true", help="Only 20 small cases, N <= 5")
|
|
92
|
+
source.add_argument(
|
|
93
|
+
"--states",
|
|
94
|
+
type=Path,
|
|
95
|
+
help="NPZ with initial_positions/final_positions and optional velocities",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument("--config", type=Path, help="JSON Config, required with --states")
|
|
98
|
+
parser.add_argument("--output", type=Path, help="JSON metrics path; otherwise print JSON")
|
|
99
|
+
parser.add_argument(
|
|
100
|
+
"--trajectories", type=Path, help="Optional sampled NPZ output for a single solve"
|
|
101
|
+
)
|
|
102
|
+
args = parser.parse_args(argv)
|
|
103
|
+
if args.smoke:
|
|
104
|
+
if args.config or args.trajectories:
|
|
105
|
+
parser.error("--config/--trajectories apply only to --states")
|
|
106
|
+
report = smoke_report()
|
|
107
|
+
success = all(record["vector"]["converged"] for record in report["records"])
|
|
108
|
+
else:
|
|
109
|
+
if args.config is None:
|
|
110
|
+
parser.error("--states requires --config")
|
|
111
|
+
solver = ContactSolver(Config.from_json(args.config))
|
|
112
|
+
with np.load(args.states, allow_pickle=False) as data:
|
|
113
|
+
solver.set_initial_states(data["initial_positions"], data.get("initial_velocities"))
|
|
114
|
+
solver.set_final_states(data["final_positions"], data.get("final_velocities"))
|
|
115
|
+
result = solver.generate_trajectories()
|
|
116
|
+
report = result["metrics"]
|
|
117
|
+
success = report["converged"]
|
|
118
|
+
if args.trajectories:
|
|
119
|
+
args.trajectories.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
np.savez(
|
|
121
|
+
args.trajectories,
|
|
122
|
+
times=result["times"],
|
|
123
|
+
**result["trajectories"],
|
|
124
|
+
contact_states=solver.solution.reshape(len(solver.contacts), solver.system.b),
|
|
125
|
+
contact_pairs=np.array([c.pair for c in solver.contacts], dtype=int).reshape(-1, 2),
|
|
126
|
+
)
|
|
127
|
+
encoded = json.dumps(
|
|
128
|
+
report,
|
|
129
|
+
indent=2,
|
|
130
|
+
allow_nan=False,
|
|
131
|
+
default=lambda value: value.item() if isinstance(value, np.generic) else str(value),
|
|
132
|
+
)
|
|
133
|
+
if args.output:
|
|
134
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
args.output.write_text(encoded + "\n")
|
|
136
|
+
else:
|
|
137
|
+
print(encoded)
|
|
138
|
+
return 0 if success else 1
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|
sci_solver/config.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Solver configuration, independent of experiment and plotting dependencies."""
|
|
2
|
+
from dataclasses import asdict, dataclass, field
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class EnvironmentConfig:
|
|
9
|
+
pos_x_min: float | None = None
|
|
10
|
+
pos_x_max: float | None = None
|
|
11
|
+
pos_y_min: float | None = None
|
|
12
|
+
pos_y_max: float | None = None
|
|
13
|
+
# Full-dimensional alternatives to the legacy x/y fields; do not mix them.
|
|
14
|
+
position_lower: list[float] | None = None
|
|
15
|
+
position_upper: list[float] | None = None
|
|
16
|
+
|
|
17
|
+
def bounds(self, dimension: int) -> tuple[np.ndarray, np.ndarray]:
|
|
18
|
+
if dimension < 2:
|
|
19
|
+
raise ValueError("Position bounds require dimension >= 2")
|
|
20
|
+
legacy = (self.pos_x_min, self.pos_y_min, self.pos_x_max, self.pos_y_max)
|
|
21
|
+
if (self.position_lower is not None or self.position_upper is not None) and any(
|
|
22
|
+
value is not None for value in legacy
|
|
23
|
+
):
|
|
24
|
+
raise ValueError("Do not mix position_lower/upper with legacy x/y bounds")
|
|
25
|
+
lower, upper = np.full(dimension, -np.inf), np.full(dimension, np.inf)
|
|
26
|
+
for axis, value in enumerate(legacy[:2]):
|
|
27
|
+
if value is not None:
|
|
28
|
+
lower[axis] = value
|
|
29
|
+
for axis, value in enumerate(legacy[2:]):
|
|
30
|
+
if value is not None:
|
|
31
|
+
upper[axis] = value
|
|
32
|
+
for values, target in ((self.position_lower, lower), (self.position_upper, upper)):
|
|
33
|
+
if values is not None:
|
|
34
|
+
array = np.asarray(values, dtype=float)
|
|
35
|
+
if array.shape != (dimension,):
|
|
36
|
+
raise ValueError("Position bounds must have one entry per spatial dimension")
|
|
37
|
+
target[:] = array
|
|
38
|
+
if np.any(np.isnan(lower)) or np.any(np.isnan(upper)) or np.any(lower > upper):
|
|
39
|
+
raise ValueError("Invalid position bounds")
|
|
40
|
+
return lower, upper
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def pos_min(self) -> np.ndarray:
|
|
44
|
+
dimension = len(
|
|
45
|
+
self.position_lower
|
|
46
|
+
if self.position_lower is not None
|
|
47
|
+
else self.position_upper if self.position_upper is not None else [0, 0]
|
|
48
|
+
)
|
|
49
|
+
return self.bounds(dimension)[0]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def pos_max(self) -> np.ndarray:
|
|
53
|
+
dimension = len(
|
|
54
|
+
self.position_lower
|
|
55
|
+
if self.position_lower is not None
|
|
56
|
+
else self.position_upper if self.position_upper is not None else [0, 0]
|
|
57
|
+
)
|
|
58
|
+
return self.bounds(dimension)[1]
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class DynamicsConfig:
|
|
62
|
+
vel_min: float | None = None
|
|
63
|
+
vel_max: float | None = None
|
|
64
|
+
acc_min: float | None = None
|
|
65
|
+
acc_max: float | None = None
|
|
66
|
+
jerk_min: float | None = None
|
|
67
|
+
jerk_max: float | None = None
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ProblemConfig:
|
|
71
|
+
n_robots: int
|
|
72
|
+
min_distance: float
|
|
73
|
+
time_horizon: float | None = None
|
|
74
|
+
dimension: int = 2
|
|
75
|
+
timestep: float = 0.1
|
|
76
|
+
environment: EnvironmentConfig = field(default_factory=EnvironmentConfig)
|
|
77
|
+
dynamics: DynamicsConfig = field(default_factory=DynamicsConfig)
|
|
78
|
+
|
|
79
|
+
def __post_init__(self):
|
|
80
|
+
if isinstance(self.n_robots, bool) or not isinstance(self.n_robots, int) or self.n_robots < 2:
|
|
81
|
+
raise ValueError("n_robots must be an integer >= 2")
|
|
82
|
+
if isinstance(self.dimension, bool) or not isinstance(self.dimension, int) or self.dimension < 2:
|
|
83
|
+
raise ValueError("dimension must be an integer >= 2")
|
|
84
|
+
if not np.isfinite(self.timestep) or self.timestep <= 0:
|
|
85
|
+
raise ValueError("timestep must be positive and finite")
|
|
86
|
+
if self.time_horizon is not None and (not np.isfinite(self.time_horizon) or self.time_horizon <= 0):
|
|
87
|
+
raise ValueError("time_horizon must be positive and finite")
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def n_timesteps(self) -> int | None:
|
|
91
|
+
return None if self.time_horizon is None else int(self.time_horizon / self.timestep)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class SolverConfig:
|
|
96
|
+
sci_detection_tol: float = 0.0
|
|
97
|
+
sci_min_contact_gap: float = 0.0
|
|
98
|
+
sci_residual_tol: float = 1e-7
|
|
99
|
+
sci_mass_tol: float = 1e-9
|
|
100
|
+
sci_root_max_iterations: int = 100
|
|
101
|
+
sci_reaction_recovery: bool = True
|
|
102
|
+
timeout: float = 300.0
|
|
103
|
+
use_analytical_jacobian: bool = True
|
|
104
|
+
|
|
105
|
+
def __post_init__(self):
|
|
106
|
+
if (isinstance(self.sci_root_max_iterations, bool)
|
|
107
|
+
or not isinstance(self.sci_root_max_iterations, int)
|
|
108
|
+
or self.sci_root_max_iterations < 0):
|
|
109
|
+
raise ValueError("sci_root_max_iterations must be a nonnegative integer")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class Config:
|
|
114
|
+
problem: ProblemConfig
|
|
115
|
+
solver: SolverConfig = field(default_factory=SolverConfig)
|
|
116
|
+
name: str = "sci"
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict:
|
|
119
|
+
return asdict(self)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_dict(cls, values: dict) -> "Config":
|
|
123
|
+
values = dict(values)
|
|
124
|
+
problem = dict(values.pop("problem"))
|
|
125
|
+
for key, schema in (("environment", EnvironmentConfig), ("dynamics", DynamicsConfig)):
|
|
126
|
+
if key in problem:
|
|
127
|
+
problem[key] = schema(**problem[key])
|
|
128
|
+
return cls(problem=ProblemConfig(**problem),
|
|
129
|
+
solver=SolverConfig(**values.pop("solver", {})), **values)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_json(cls, path: str | Path) -> "Config":
|
|
133
|
+
return cls.from_dict(json.loads(Path(path).read_text()))
|
|
134
|
+
|
|
135
|
+
def to_json(self, path: str | Path) -> None:
|
|
136
|
+
path = Path(path)
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
path.write_text(json.dumps(self.to_dict(), indent=2, allow_nan=False) + "\n")
|
sci_solver/evaluation.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Measurements of the continuous trajectory."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
def continuous_acceleration_cost(solver, *, knots=None):
|
|
5
|
+
"""Exact cubic-path integral; explicit knots support saved baseline adapters."""
|
|
6
|
+
if knots is None:
|
|
7
|
+
if hasattr(solver, "trajectory"):
|
|
8
|
+
knots = solver.trajectory.times
|
|
9
|
+
else:
|
|
10
|
+
knots = sorted(
|
|
11
|
+
{
|
|
12
|
+
t
|
|
13
|
+
for path in solver._trajectories.values()
|
|
14
|
+
for _, a, b in path.segments
|
|
15
|
+
for t in (a, b)
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
cost = 0.0
|
|
19
|
+
for a, b in zip(knots[:-1], knots[1:], strict=True):
|
|
20
|
+
times = (a + b) / 2 + (b - a) / (2 * np.sqrt(3)) * np.array([-1.0, 1.0])
|
|
21
|
+
cost += (b - a) / 2 * np.sum(np.asarray(solver.sample(times=times)[3]) ** 2)
|
|
22
|
+
return float(cost)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/* Batched continuous separation kernel: same derivative partition root isolation.
|
|
2
|
+
* No fast-math, no discarded small leading terms, no sampling acceptance. */
|
|
3
|
+
#include <math.h>
|
|
4
|
+
#include <float.h>
|
|
5
|
+
#include <stdlib.h>
|
|
6
|
+
#ifdef _MSC_VER
|
|
7
|
+
#include <malloc.h>
|
|
8
|
+
#endif
|
|
9
|
+
|
|
10
|
+
static double value(const double *p, int degree, double x) {
|
|
11
|
+
double y=p[degree];
|
|
12
|
+
for(int i=degree-1;i>=0;--i)y=y*x+p[i];
|
|
13
|
+
return y;
|
|
14
|
+
}
|
|
15
|
+
static int compare(const void *a,const void *b) {
|
|
16
|
+
double x=*(const double*)a,y=*(const double*)b;
|
|
17
|
+
return (x>y)-(x<y);
|
|
18
|
+
}
|
|
19
|
+
static int unique(double *a,int n) {
|
|
20
|
+
qsort(a,n,sizeof(double),compare);
|
|
21
|
+
int k=0;
|
|
22
|
+
for(int i=0;i<n;++i)if(k==0 || a[i]!=a[k-1])a[k++]=a[i];
|
|
23
|
+
return k;
|
|
24
|
+
}
|
|
25
|
+
static double bracket(const double *p,int degree,double a,double b,double fa) {
|
|
26
|
+
double x=(a+b)*0.5;
|
|
27
|
+
for(int k=0;k<100;++k) {
|
|
28
|
+
double fx=value(p,degree,x);
|
|
29
|
+
if(fx==0)return x;
|
|
30
|
+
if(signbit(fx)==signbit(fa)){a=x;fa=fx;}else b=x;
|
|
31
|
+
if(b-a<=1e-14+4*DBL_EPSILON*fabs(x))return (a+b)*0.5;
|
|
32
|
+
double derivative=degree*p[degree];
|
|
33
|
+
for(int j=degree-1;j>=1;--j)derivative=derivative*x+j*p[j];
|
|
34
|
+
double trial=x-fx/derivative;
|
|
35
|
+
/* Safeguard Newton by insisting on progress inside the bracket. */
|
|
36
|
+
/* Every other step bisects: at least 50 halvings within the cap,
|
|
37
|
+
* even for a very flat root. There is no silent loose termination. */
|
|
38
|
+
x=(k%2==0&&isfinite(trial)&&trial>a+0.1*(b-a)&&trial<b-0.1*(b-a))?trial:(a+b)*0.5;
|
|
39
|
+
}
|
|
40
|
+
return x;
|
|
41
|
+
}
|
|
42
|
+
static int roots(const double *input,int degree,double *out) {
|
|
43
|
+
while(degree>0 && input[degree]==0)--degree;
|
|
44
|
+
if(degree==0)return 0;
|
|
45
|
+
double p[6],largest=0;
|
|
46
|
+
for(int i=0;i<=degree;++i)largest=fmax(largest,fabs(input[i]));
|
|
47
|
+
for(int i=0;i<=degree;++i)p[i]=input[i]/largest;
|
|
48
|
+
if(degree==1){double x=-p[0]/p[1];if(x>=0&&x<=1){out[0]=x;return 1;}return 0;}
|
|
49
|
+
double derivative[5],critical[32],partition[34];
|
|
50
|
+
for(int i=1;i<=degree;++i)derivative[i-1]=i*p[i];
|
|
51
|
+
int count=roots(derivative,degree-1,critical),n=0;
|
|
52
|
+
partition[0]=0;partition[1]=1;
|
|
53
|
+
for(int i=0;i<count;++i){partition[i+2]=critical[i];out[n++]=critical[i];}
|
|
54
|
+
int parts=unique(partition,count+2);
|
|
55
|
+
for(int i=0;i<parts-1;++i){
|
|
56
|
+
double a=partition[i],b=partition[i+1],fa=value(p,degree,a),fb=value(p,degree,b);
|
|
57
|
+
if(fa==0)out[n++]=a;
|
|
58
|
+
if(fb==0)out[n++]=b;
|
|
59
|
+
if(fa!=0&&fb!=0&&signbit(fa)!=signbit(fb))out[n++]=bracket(p,degree,a,b,fa);
|
|
60
|
+
}
|
|
61
|
+
return unique(out,n);
|
|
62
|
+
}
|
|
63
|
+
static double distance(const double *c,int d,double t) {
|
|
64
|
+
double sum=0;
|
|
65
|
+
for(int j=0;j<d;++j){double x=c[j]+t*(c[d+j]+t*(c[2*d+j]+t*c[3*d+j]));sum+=x*x;}
|
|
66
|
+
return sqrt(sum);
|
|
67
|
+
}
|
|
68
|
+
static double lower_bound(const double *c,int d,double *guard) {
|
|
69
|
+
#ifdef _MSC_VER
|
|
70
|
+
/* MSVC has no C variable-length arrays; retain the same stack workspace. */
|
|
71
|
+
double *controls=_alloca((size_t)4*d*sizeof(double));
|
|
72
|
+
double *direction=_alloca((size_t)d*sizeof(double));
|
|
73
|
+
double *delta=_alloca((size_t)d*sizeof(double));
|
|
74
|
+
double sq=0,dot=0,magnitude=0;
|
|
75
|
+
#else
|
|
76
|
+
double controls[4*d],direction[d],delta[d],sq=0,dot=0,magnitude=0;
|
|
77
|
+
#endif
|
|
78
|
+
for(int j=0;j<d;++j){
|
|
79
|
+
controls[j]=c[j];controls[d+j]=c[j]+c[d+j]/3;
|
|
80
|
+
controls[2*d+j]=c[j]+2*c[d+j]/3+c[2*d+j]/3;
|
|
81
|
+
controls[3*d+j]=c[j]+c[d+j]+c[2*d+j]+c[3*d+j];
|
|
82
|
+
delta[j]=controls[3*d+j]-c[j];sq+=delta[j]*delta[j];dot+=c[j]*delta[j];
|
|
83
|
+
for(int k=0;k<4;++k)magnitude+=fabs(c[k*d+j]);
|
|
84
|
+
}
|
|
85
|
+
double u=sq>0?fmax(0,fmin(1,-dot/sq)):0,norm=0,box=0;
|
|
86
|
+
for(int j=0;j<d;++j){
|
|
87
|
+
double lo=controls[j],hi=lo;
|
|
88
|
+
for(int k=1;k<4;++k){lo=fmin(lo,controls[k*d+j]);hi=fmax(hi,controls[k*d+j]);}
|
|
89
|
+
double near=fmax(0,fmax(lo,-hi));box+=near*near;
|
|
90
|
+
direction[j]=c[j]+u*delta[j];norm+=direction[j]*direction[j];
|
|
91
|
+
}
|
|
92
|
+
norm=sqrt(norm);double support=INFINITY;
|
|
93
|
+
if(norm>0){for(int k=0;k<4;++k){double s=0;for(int j=0;j<d;++j)s+=controls[k*d+j]*(direction[j]/norm);support=fmin(support,s);}}
|
|
94
|
+
else support=0;
|
|
95
|
+
*guard=1024*DBL_EPSILON*fmax(1,magnitude);
|
|
96
|
+
return fmax(0,fmax(sqrt(box),support)-*guard);
|
|
97
|
+
}
|
|
98
|
+
/* Outputs per-polynomial minima. Pruned entries stay +inf. */
|
|
99
|
+
int batch_minima(const double *coefficients,int count,int d,int pruning,double *times,double *distances,int *solved) {
|
|
100
|
+
double upper=INFINITY,best=INFINITY;
|
|
101
|
+
*solved=0;
|
|
102
|
+
if(pruning)for(int i=0;i<count;++i){
|
|
103
|
+
const double *c=coefficients+i*4*d;double magnitude=0;
|
|
104
|
+
for(int j=0;j<d;++j)for(int k=0;k<4;++k)magnitude+=fabs(c[k*d+j]);
|
|
105
|
+
double guard=1024*DBL_EPSILON*fmax(1,magnitude);
|
|
106
|
+
upper=fmin(upper,fmin(distance(c,d,0),fmin(distance(c,d,.5),distance(c,d,1)))+guard);
|
|
107
|
+
}
|
|
108
|
+
for(int i=0;i<count;++i){
|
|
109
|
+
const double *c=coefficients+i*4*d;double guard=0;
|
|
110
|
+
distances[i]=INFINITY;times[i]=0;
|
|
111
|
+
if(pruning && lower_bound(c,d,&guard)>fmin(upper,best)+guard)continue;
|
|
112
|
+
++*solved;
|
|
113
|
+
double p[6]={0},candidates[64];
|
|
114
|
+
for(int j=0;j<d;++j)for(int k=0;k<4;++k)for(int l=0;l<3;++l)p[k+l]+=c[k*d+j]*(l+1)*c[(l+1)*d+j];
|
|
115
|
+
for(int k=0;k<6;++k)if(!isfinite(p[k]))return -1;
|
|
116
|
+
int nr=roots(p,5,candidates);
|
|
117
|
+
candidates[nr++]=0;candidates[nr++]=1;nr=unique(candidates,nr);
|
|
118
|
+
for(int k=0;k<nr;++k){double q=distance(c,d,candidates[k]);if(q<distances[i]){times[i]=candidates[k];distances[i]=q;}}
|
|
119
|
+
best=fmin(best,distances[i]);
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Python buffer API keeps the build independent of NumPy headers. */
|
|
125
|
+
#define PY_SSIZE_T_CLEAN
|
|
126
|
+
#include <Python.h>
|
|
127
|
+
#include <string.h>
|
|
128
|
+
|
|
129
|
+
static int double_buffer(PyObject *object, Py_buffer *buffer, int writable) {
|
|
130
|
+
int flags = PyBUF_FORMAT | PyBUF_C_CONTIGUOUS;
|
|
131
|
+
if (writable) flags |= PyBUF_WRITABLE;
|
|
132
|
+
if (PyObject_GetBuffer(object, buffer, flags) < 0) return -1;
|
|
133
|
+
if (buffer->itemsize != sizeof(double) || strcmp(buffer->format, "d") != 0) {
|
|
134
|
+
PyErr_SetString(PyExc_ValueError, "Expected contiguous native float64 buffers");
|
|
135
|
+
return -1;
|
|
136
|
+
}
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
static PyObject *python_minima(PyObject *self, PyObject *args) {
|
|
141
|
+
PyObject *coefficients, *times, *distances;
|
|
142
|
+
Py_buffer c = {0}, t = {0}, r = {0};
|
|
143
|
+
int count, dimension, pruning, solved, status;
|
|
144
|
+
if (!PyArg_ParseTuple(args, "OiipOO", &coefficients, &count, &dimension,
|
|
145
|
+
&pruning, ×, &distances)) return NULL;
|
|
146
|
+
if (count < 0 || dimension < 1 ||
|
|
147
|
+
(Py_ssize_t)count > PY_SSIZE_T_MAX / (Py_ssize_t)sizeof(double) / 4 / dimension) {
|
|
148
|
+
PyErr_SetString(PyExc_ValueError, "Invalid batch dimensions");
|
|
149
|
+
return NULL;
|
|
150
|
+
}
|
|
151
|
+
if (double_buffer(coefficients, &c, 0) < 0 || double_buffer(times, &t, 1) < 0 ||
|
|
152
|
+
double_buffer(distances, &r, 1) < 0) goto error;
|
|
153
|
+
if (c.len != (Py_ssize_t)count * 4 * dimension * (Py_ssize_t)sizeof(double) ||
|
|
154
|
+
t.len != (Py_ssize_t)count * (Py_ssize_t)sizeof(double) || r.len != t.len) {
|
|
155
|
+
PyErr_SetString(PyExc_ValueError, "Buffer sizes do not match batch dimensions");
|
|
156
|
+
goto error;
|
|
157
|
+
}
|
|
158
|
+
status = batch_minima(c.buf, count, dimension, pruning, t.buf, r.buf, &solved);
|
|
159
|
+
PyBuffer_Release(&c); PyBuffer_Release(&t); PyBuffer_Release(&r);
|
|
160
|
+
if (status) {
|
|
161
|
+
PyErr_SetString(PyExc_ValueError, "Nonfinite distance derivative polynomial");
|
|
162
|
+
return NULL;
|
|
163
|
+
}
|
|
164
|
+
return PyLong_FromLong(solved);
|
|
165
|
+
error:
|
|
166
|
+
if (c.obj) PyBuffer_Release(&c);
|
|
167
|
+
if (t.obj) PyBuffer_Release(&t);
|
|
168
|
+
if (r.obj) PyBuffer_Release(&r);
|
|
169
|
+
return NULL;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
static PyMethodDef methods[] = {
|
|
173
|
+
{"minima", python_minima, METH_VARARGS, "Compute batched cubic distance minima."},
|
|
174
|
+
{NULL, NULL, 0, NULL}
|
|
175
|
+
};
|
|
176
|
+
static struct PyModuleDef module = {PyModuleDef_HEAD_INIT, "_cubic_minima", NULL, -1, methods};
|
|
177
|
+
PyMODINIT_FUNC PyInit__cubic_minima(void) { return PyModule_Create(&module); }
|
|
Binary file
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Base solver class for trajectory optimization."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Solver(ABC):
|
|
11
|
+
# Opt in to the general-dimensional base endpoint setters. ContactSolver
|
|
12
|
+
# has its own setters. Legacy and prioritized solvers retain planar guards.
|
|
13
|
+
supports_nd_states = False
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Config):
|
|
16
|
+
self.config = config
|
|
17
|
+
|
|
18
|
+
self.N = config.problem.n_robots
|
|
19
|
+
self.d = config.problem.dimension
|
|
20
|
+
if not isinstance(self.d, int) or self.d < 2:
|
|
21
|
+
raise ValueError("problem.dimension must be an integer >= 2")
|
|
22
|
+
self.h = config.problem.timestep
|
|
23
|
+
self.R = config.problem.min_distance
|
|
24
|
+
|
|
25
|
+
self.T = config.problem.time_horizon
|
|
26
|
+
self.K = config.problem.n_timesteps if self.T is not None else None
|
|
27
|
+
|
|
28
|
+
self.T_MULTIPLIER = 1.0
|
|
29
|
+
|
|
30
|
+
# Environment limits (None = unconstrained)
|
|
31
|
+
self.pos_min, self.pos_max = config.problem.environment.bounds(self.d)
|
|
32
|
+
|
|
33
|
+
# Dynamics limits (None = unconstrained)
|
|
34
|
+
self.vel_min = config.problem.dynamics.vel_min
|
|
35
|
+
self.vel_max = config.problem.dynamics.vel_max
|
|
36
|
+
self.acc_min = config.problem.dynamics.acc_min
|
|
37
|
+
self.acc_max = config.problem.dynamics.acc_max
|
|
38
|
+
self.jerk_min = config.problem.dynamics.jerk_min
|
|
39
|
+
self.jerk_max = config.problem.dynamics.jerk_max
|
|
40
|
+
|
|
41
|
+
self.initial_positions = None
|
|
42
|
+
self.initial_velocities = None
|
|
43
|
+
self.final_positions = None
|
|
44
|
+
self.final_velocities = None
|
|
45
|
+
self.trajectories = None
|
|
46
|
+
|
|
47
|
+
def set_initial_states(self, positions, velocities=None):
|
|
48
|
+
self.initial_positions, self.initial_velocities = self._endpoint_states(
|
|
49
|
+
positions, velocities
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def set_final_states(self, positions, velocities=None):
|
|
53
|
+
self.final_positions, self.final_velocities = self._endpoint_states(positions, velocities)
|
|
54
|
+
|
|
55
|
+
if self.T is None:
|
|
56
|
+
if self.initial_positions is None:
|
|
57
|
+
raise ValueError("Call set_initial_states() before set_final_states()")
|
|
58
|
+
self.T = self._auto_time_horizon()
|
|
59
|
+
self.K = int(round(self.T / self.h))
|
|
60
|
+
|
|
61
|
+
def _endpoint_states(self, positions, velocities):
|
|
62
|
+
if self.d != 2 and not self.supports_nd_states:
|
|
63
|
+
raise ValueError(f"{type(self).__name__} supports only dimension=2")
|
|
64
|
+
positions = np.asarray(positions, dtype=float)
|
|
65
|
+
velocities = (
|
|
66
|
+
np.zeros_like(positions) if velocities is None else np.asarray(velocities, dtype=float)
|
|
67
|
+
)
|
|
68
|
+
shapes = ((self.N, self.d), (self.N * self.d,))
|
|
69
|
+
if positions.shape not in shapes or velocities.shape not in shapes:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Expected endpoint positions/velocities of shape ({self.N}, {self.d}) or flattened"
|
|
72
|
+
)
|
|
73
|
+
if not np.all(np.isfinite(positions)) or not np.all(np.isfinite(velocities)):
|
|
74
|
+
raise ValueError("Endpoint states must be finite")
|
|
75
|
+
return positions.ravel().copy(), velocities.ravel().copy()
|
|
76
|
+
|
|
77
|
+
def _auto_time_horizon(self) -> float:
|
|
78
|
+
initial = self.initial_positions.reshape(-1, self.d)
|
|
79
|
+
final = self.final_positions.reshape(-1, self.d)
|
|
80
|
+
|
|
81
|
+
distances = np.linalg.norm(final - initial, axis=1)
|
|
82
|
+
max_distance = distances.max()
|
|
83
|
+
|
|
84
|
+
v_max = self.vel_max if self.vel_max is not None else 2.0
|
|
85
|
+
T = (1.5 * max_distance) / v_max
|
|
86
|
+
T *= self.T_MULTIPLIER
|
|
87
|
+
|
|
88
|
+
K = int(np.ceil(T / self.h))
|
|
89
|
+
T = K * self.h
|
|
90
|
+
|
|
91
|
+
return float(T)
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def generate_trajectories(self, **kwargs):
|
|
95
|
+
pass
|