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 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")
@@ -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,4 @@
1
+ """Numerical implementation of sequential contact insertion."""
2
+ from .contact_solver import ContactSolver, RelativeTrajectory
3
+
4
+ __all__ = ["ContactSolver", "RelativeTrajectory"]
@@ -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, &times, &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); }
@@ -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