planarcurve 0.1.0__py3-none-any.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.
- planarcurve/__init__.py +52 -0
- planarcurve/_version.py +24 -0
- planarcurve/bezier.py +211 -0
- planarcurve/curvature.py +82 -0
- planarcurve/fitting/__init__.py +3 -0
- planarcurve/fitting/parameters.py +110 -0
- planarcurve/fitting/policy.py +17 -0
- planarcurve/fitting/spline.py +217 -0
- planarcurve/fitting/trajectory.py +575 -0
- planarcurve/polyline.py +177 -0
- planarcurve/py.typed +0 -0
- planarcurve/spline.py +221 -0
- planarcurve/svg.py +298 -0
- planarcurve/trajectory.py +388 -0
- planarcurve/transforms.py +136 -0
- planarcurve/types.py +75 -0
- planarcurve-0.1.0.dist-info/METADATA +371 -0
- planarcurve-0.1.0.dist-info/RECORD +21 -0
- planarcurve-0.1.0.dist-info/WHEEL +5 -0
- planarcurve-0.1.0.dist-info/licenses/LICENSE +201 -0
- planarcurve-0.1.0.dist-info/top_level.txt +1 -0
planarcurve/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Planar curve and trajectory mathematics."""
|
|
2
|
+
|
|
3
|
+
from .trajectory import (
|
|
4
|
+
HeadingRateChange,
|
|
5
|
+
HeadingRateProfile,
|
|
6
|
+
Pose2D,
|
|
7
|
+
Trajectory2D,
|
|
8
|
+
TrajectorySample,
|
|
9
|
+
curvature,
|
|
10
|
+
evaluate_heading,
|
|
11
|
+
evaluate_heading_rate,
|
|
12
|
+
evaluate_trajectory,
|
|
13
|
+
evaluate_trajectory_at,
|
|
14
|
+
integrated_heading_rate,
|
|
15
|
+
sample_trajectory_by_arclength,
|
|
16
|
+
trajectory_end_pose,
|
|
17
|
+
trajectory_to_cubic_path,
|
|
18
|
+
trajectory_total_turn,
|
|
19
|
+
)
|
|
20
|
+
from .transforms import Affine2D, Similarity2D
|
|
21
|
+
from .types import CubicBezier, CubicPath, CubicSegment, Point2D, QuadraticBezier
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
except ImportError: # source tree before setuptools_scm generated _version.py
|
|
26
|
+
__version__ = "0+unknown"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Affine2D",
|
|
30
|
+
"CubicBezier",
|
|
31
|
+
"CubicPath",
|
|
32
|
+
"QuadraticBezier",
|
|
33
|
+
"CubicSegment",
|
|
34
|
+
"HeadingRateChange",
|
|
35
|
+
"HeadingRateProfile",
|
|
36
|
+
"Point2D",
|
|
37
|
+
"Pose2D",
|
|
38
|
+
"Similarity2D",
|
|
39
|
+
"Trajectory2D",
|
|
40
|
+
"TrajectorySample",
|
|
41
|
+
"__version__",
|
|
42
|
+
"curvature",
|
|
43
|
+
"evaluate_heading",
|
|
44
|
+
"evaluate_heading_rate",
|
|
45
|
+
"evaluate_trajectory",
|
|
46
|
+
"evaluate_trajectory_at",
|
|
47
|
+
"integrated_heading_rate",
|
|
48
|
+
"sample_trajectory_by_arclength",
|
|
49
|
+
"trajectory_end_pose",
|
|
50
|
+
"trajectory_to_cubic_path",
|
|
51
|
+
"trajectory_total_turn",
|
|
52
|
+
]
|
planarcurve/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g96d8146d2'
|
planarcurve/bezier.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from .types import CubicBezier, QuadraticBezier
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _parameters(t: np.ndarray) -> np.ndarray:
|
|
9
|
+
values = np.asarray(t, dtype=float)
|
|
10
|
+
if values.ndim != 1 or not np.isfinite(values).all():
|
|
11
|
+
raise ValueError("parameters must be a finite one-dimensional array")
|
|
12
|
+
if np.any((values < 0.0) | (values > 1.0)):
|
|
13
|
+
raise ValueError("parameters must lie in [0, 1]")
|
|
14
|
+
return values
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def evaluate_quadratic(p0, p1, p2, t: np.ndarray) -> np.ndarray:
|
|
18
|
+
u = _parameters(t)[:, None]
|
|
19
|
+
one = 1.0 - u
|
|
20
|
+
return one**2 * np.asarray(p0) + 2.0 * one * u * np.asarray(p1) + u**2 * np.asarray(p2)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def evaluate_quadratic_bezier(curve: QuadraticBezier, t: np.ndarray) -> np.ndarray:
|
|
24
|
+
return evaluate_quadratic(curve.p0, curve.p1, curve.p2, t)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def quadratic_derivative(curve: QuadraticBezier, t: np.ndarray) -> np.ndarray:
|
|
28
|
+
u = _parameters(t)[:, None]
|
|
29
|
+
return 2.0 * (
|
|
30
|
+
(1.0 - u) * (np.asarray(curve.p1) - np.asarray(curve.p0))
|
|
31
|
+
+ u * (np.asarray(curve.p2) - np.asarray(curve.p1))
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def evaluate_cubic(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
|
|
36
|
+
u = _parameters(t)[:, None]
|
|
37
|
+
one = 1.0 - u
|
|
38
|
+
return (
|
|
39
|
+
one**3 * np.asarray(curve.p0)
|
|
40
|
+
+ 3.0 * one**2 * u * np.asarray(curve.p1)
|
|
41
|
+
+ 3.0 * one * u**2 * np.asarray(curve.p2)
|
|
42
|
+
+ u**3 * np.asarray(curve.p3)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cubic_derivative(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
|
|
47
|
+
u = _parameters(t)[:, None]
|
|
48
|
+
one = 1.0 - u
|
|
49
|
+
return 3.0 * (
|
|
50
|
+
one**2 * (np.asarray(curve.p1) - np.asarray(curve.p0))
|
|
51
|
+
+ 2.0 * one * u * (np.asarray(curve.p2) - np.asarray(curve.p1))
|
|
52
|
+
+ u**2 * (np.asarray(curve.p3) - np.asarray(curve.p2))
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cubic_second_derivative(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
|
|
57
|
+
u = _parameters(t)[:, None]
|
|
58
|
+
return 6.0 * (
|
|
59
|
+
(1.0 - u) * (np.asarray(curve.p2) - 2.0 * np.asarray(curve.p1) + np.asarray(curve.p0))
|
|
60
|
+
+ u * (np.asarray(curve.p3) - 2.0 * np.asarray(curve.p2) + np.asarray(curve.p1))
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cubic_tangent(curve: CubicBezier, t: np.ndarray, *, unit: bool = True) -> np.ndarray:
|
|
65
|
+
derivative = cubic_derivative(curve, t)
|
|
66
|
+
if not unit:
|
|
67
|
+
return derivative
|
|
68
|
+
norms = np.linalg.norm(derivative, axis=1)
|
|
69
|
+
return np.divide(
|
|
70
|
+
derivative,
|
|
71
|
+
norms[:, None],
|
|
72
|
+
out=np.zeros_like(derivative),
|
|
73
|
+
where=norms[:, None] > 1e-12,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _signed_curvature(first: np.ndarray, second: np.ndarray) -> np.ndarray:
|
|
78
|
+
cross = first[:, 0] * second[:, 1] - first[:, 1] * second[:, 0]
|
|
79
|
+
speed = np.linalg.norm(first, axis=1)
|
|
80
|
+
return np.divide(cross, speed**3, out=np.zeros_like(cross), where=speed > 1e-12)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cubic_curvature(curve: CubicBezier, t: np.ndarray) -> np.ndarray:
|
|
84
|
+
return _signed_curvature(cubic_derivative(curve, t), cubic_second_derivative(curve, t))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def quadratic_tangent(
|
|
88
|
+
curve: QuadraticBezier, t: np.ndarray, *, unit: bool = True
|
|
89
|
+
) -> np.ndarray:
|
|
90
|
+
derivative = quadratic_derivative(curve, t)
|
|
91
|
+
if not unit:
|
|
92
|
+
return derivative
|
|
93
|
+
norms = np.linalg.norm(derivative, axis=1)
|
|
94
|
+
return np.divide(
|
|
95
|
+
derivative,
|
|
96
|
+
norms[:, None],
|
|
97
|
+
out=np.zeros_like(derivative),
|
|
98
|
+
where=norms[:, None] > 1e-12,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def quadratic_curvature(curve: QuadraticBezier, t: np.ndarray) -> np.ndarray:
|
|
103
|
+
derivative = quadratic_derivative(curve, t)
|
|
104
|
+
second = np.broadcast_to(
|
|
105
|
+
2.0 * (np.asarray(curve.p2) - 2.0 * np.asarray(curve.p1) + np.asarray(curve.p0)),
|
|
106
|
+
derivative.shape,
|
|
107
|
+
)
|
|
108
|
+
return _signed_curvature(derivative, second)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def split_cubic(curve: CubicBezier, t: float = 0.5) -> tuple[CubicBezier, CubicBezier]:
|
|
112
|
+
parameter = _parameters(np.array([t]))[0]
|
|
113
|
+
p0, p1, p2, p3 = (
|
|
114
|
+
np.asarray(point, dtype=float)
|
|
115
|
+
for point in (curve.p0, curve.p1, curve.p2, curve.p3)
|
|
116
|
+
)
|
|
117
|
+
p01 = (1 - parameter) * p0 + parameter * p1
|
|
118
|
+
p12 = (1 - parameter) * p1 + parameter * p2
|
|
119
|
+
p23 = (1 - parameter) * p2 + parameter * p3
|
|
120
|
+
p012 = (1 - parameter) * p01 + parameter * p12
|
|
121
|
+
p123 = (1 - parameter) * p12 + parameter * p23
|
|
122
|
+
middle = (1 - parameter) * p012 + parameter * p123
|
|
123
|
+
return (
|
|
124
|
+
CubicBezier(tuple(p0), tuple(p01), tuple(p012), tuple(middle)),
|
|
125
|
+
CubicBezier(tuple(middle), tuple(p123), tuple(p23), tuple(p3)),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _point_line_distance(point: np.ndarray, start: np.ndarray, end: np.ndarray) -> float:
|
|
130
|
+
direction = end - start
|
|
131
|
+
norm = np.linalg.norm(direction)
|
|
132
|
+
if norm <= 1e-12:
|
|
133
|
+
return float(np.linalg.norm(point - start))
|
|
134
|
+
offset = point - start
|
|
135
|
+
cross = direction[0] * offset[1] - direction[1] * offset[0]
|
|
136
|
+
return float(abs(cross) / norm)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def flatten_cubic(
|
|
140
|
+
curve: CubicBezier, *, tolerance: float = 1e-3, max_depth: int = 16
|
|
141
|
+
) -> np.ndarray:
|
|
142
|
+
if not np.isfinite(tolerance) or tolerance <= 0.0:
|
|
143
|
+
raise ValueError("tolerance must be positive and finite")
|
|
144
|
+
if isinstance(max_depth, bool) or max_depth < 0:
|
|
145
|
+
raise ValueError("max_depth must be nonnegative")
|
|
146
|
+
points = [np.asarray(curve.p0, dtype=float)]
|
|
147
|
+
|
|
148
|
+
def flatten(piece: CubicBezier, depth: int) -> None:
|
|
149
|
+
p0, p1, p2, p3 = (
|
|
150
|
+
np.asarray(point, dtype=float) for point in (piece.p0, piece.p1, piece.p2, piece.p3)
|
|
151
|
+
)
|
|
152
|
+
flatness = max(_point_line_distance(p1, p0, p3), _point_line_distance(p2, p0, p3))
|
|
153
|
+
if flatness <= tolerance:
|
|
154
|
+
points.append(p3)
|
|
155
|
+
return
|
|
156
|
+
if depth == 0:
|
|
157
|
+
raise ValueError("cubic flattening did not meet tolerance")
|
|
158
|
+
left, right = split_cubic(piece)
|
|
159
|
+
flatten(left, depth - 1)
|
|
160
|
+
flatten(right, depth - 1)
|
|
161
|
+
|
|
162
|
+
flatten(curve, max_depth)
|
|
163
|
+
return np.asarray(points)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cubic_length(curve: CubicBezier, *, tolerance: float = 1e-6, max_depth: int = 16) -> float:
|
|
167
|
+
if not np.isfinite(tolerance) or tolerance <= 0.0:
|
|
168
|
+
raise ValueError("tolerance must be positive and finite")
|
|
169
|
+
if isinstance(max_depth, bool) or max_depth < 0:
|
|
170
|
+
raise ValueError("max_depth must be nonnegative")
|
|
171
|
+
|
|
172
|
+
def estimate(piece: CubicBezier, local_tolerance: float, depth: int) -> float:
|
|
173
|
+
p0, p1, p2, p3 = (np.asarray(point) for point in (piece.p0, piece.p1, piece.p2, piece.p3))
|
|
174
|
+
chord = float(np.linalg.norm(p3 - p0))
|
|
175
|
+
polygon = float(
|
|
176
|
+
np.linalg.norm(p1 - p0) + np.linalg.norm(p2 - p1) + np.linalg.norm(p3 - p2)
|
|
177
|
+
)
|
|
178
|
+
if polygon - chord <= local_tolerance:
|
|
179
|
+
return (polygon + chord) / 2.0
|
|
180
|
+
if depth == 0:
|
|
181
|
+
raise ValueError("cubic length approximation did not meet tolerance")
|
|
182
|
+
left, right = split_cubic(piece)
|
|
183
|
+
return estimate(left, local_tolerance / 2.0, depth - 1) + estimate(
|
|
184
|
+
right, local_tolerance / 2.0, depth - 1
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return estimate(curve, tolerance, max_depth)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _quadratic_as_cubic(curve: QuadraticBezier) -> CubicBezier:
|
|
191
|
+
p0, p1, p2 = (np.asarray(point, dtype=float) for point in (curve.p0, curve.p1, curve.p2))
|
|
192
|
+
return CubicBezier(
|
|
193
|
+
tuple(p0),
|
|
194
|
+
tuple(p0 + (2.0 / 3.0) * (p1 - p0)),
|
|
195
|
+
tuple(p2 + (2.0 / 3.0) * (p1 - p2)),
|
|
196
|
+
tuple(p2),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def flatten_quadratic(
|
|
201
|
+
curve: QuadraticBezier, *, tolerance: float = 1e-3, max_depth: int = 16
|
|
202
|
+
) -> np.ndarray:
|
|
203
|
+
return flatten_cubic(
|
|
204
|
+
_quadratic_as_cubic(curve), tolerance=tolerance, max_depth=max_depth
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def quadratic_length(
|
|
209
|
+
curve: QuadraticBezier, *, tolerance: float = 1e-6, max_depth: int = 16
|
|
210
|
+
) -> float:
|
|
211
|
+
return cubic_length(_quadratic_as_cubic(curve), tolerance=tolerance, max_depth=max_depth)
|
planarcurve/curvature.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Compatibility imports for the former curvature-module API.
|
|
2
|
+
|
|
3
|
+
New code should import the neutral trajectory API from :mod:`planarcurve.trajectory`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .trajectory import (
|
|
11
|
+
HeadingRateChange,
|
|
12
|
+
HeadingRateProfile,
|
|
13
|
+
Pose2D,
|
|
14
|
+
Trajectory2D,
|
|
15
|
+
TrajectorySample,
|
|
16
|
+
evaluate_heading,
|
|
17
|
+
evaluate_heading_rate,
|
|
18
|
+
evaluate_trajectory,
|
|
19
|
+
evaluate_trajectory_at,
|
|
20
|
+
integrated_heading_rate,
|
|
21
|
+
sample_trajectory_by_arclength,
|
|
22
|
+
trajectory_end_pose,
|
|
23
|
+
trajectory_to_cubic_path,
|
|
24
|
+
trajectory_total_turn,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CurvatureChange",
|
|
29
|
+
"CurvatureProfile",
|
|
30
|
+
"CurvatureSample",
|
|
31
|
+
"CurvatureStrokeGeometry",
|
|
32
|
+
"CurvatureStrokeSample",
|
|
33
|
+
"CurvatureTrajectory",
|
|
34
|
+
"Pose2D",
|
|
35
|
+
"StrokePose",
|
|
36
|
+
"evaluate_curvature_stroke",
|
|
37
|
+
"evaluate_heading",
|
|
38
|
+
"evaluate_heading_rate",
|
|
39
|
+
"evaluate_stroke_at",
|
|
40
|
+
"integrated_heading_rate",
|
|
41
|
+
"sample_by_arclength",
|
|
42
|
+
"sample_curvature_stroke",
|
|
43
|
+
"to_cubic_beziers",
|
|
44
|
+
"trajectory_end_pose",
|
|
45
|
+
"trajectory_total_turn",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
CurvatureChange = HeadingRateChange
|
|
50
|
+
CurvatureProfile = HeadingRateProfile
|
|
51
|
+
CurvatureTrajectory = Trajectory2D
|
|
52
|
+
CurvatureStrokeGeometry = Trajectory2D
|
|
53
|
+
CurvatureSample = TrajectorySample
|
|
54
|
+
CurvatureStrokeSample = TrajectorySample
|
|
55
|
+
StrokePose = Pose2D
|
|
56
|
+
evaluate_curvature_stroke = evaluate_trajectory
|
|
57
|
+
evaluate_stroke_at = evaluate_trajectory_at
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def curvature_to_cubic_segments(
|
|
61
|
+
trajectory: Trajectory2D, *, max_error: float = 1e-4
|
|
62
|
+
):
|
|
63
|
+
return list(trajectory_to_cubic_path(trajectory, max_error=max_error).segments)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sample_curvature_stroke(
|
|
67
|
+
trajectory: Trajectory2D, *, count: int = 256, samples: int = 64
|
|
68
|
+
) -> np.ndarray:
|
|
69
|
+
return sample_trajectory_by_arclength(
|
|
70
|
+
trajectory, samples, integration_count=count
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sample_by_arclength(
|
|
75
|
+
trajectory: Trajectory2D, count: int, *, integration_count: int = 256
|
|
76
|
+
) -> np.ndarray:
|
|
77
|
+
return sample_trajectory_by_arclength(
|
|
78
|
+
trajectory, count, integration_count=integration_count
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
to_cubic_beziers = trajectory_to_cubic_path
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Generic parameterizations for ordered positions, intervals, and positives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ordered_positions_from_logits(
|
|
9
|
+
logits: np.ndarray,
|
|
10
|
+
*,
|
|
11
|
+
min_spacing: float = 0.0,
|
|
12
|
+
start: float = 0.0,
|
|
13
|
+
end: float = 1.0,
|
|
14
|
+
) -> np.ndarray:
|
|
15
|
+
"""Decode interval logits into strictly ordered positions in ``(start, end)``."""
|
|
16
|
+
values = np.asarray(logits, dtype=float)
|
|
17
|
+
if values.ndim != 1 or not np.isfinite(values).all():
|
|
18
|
+
raise ValueError("logits must be a finite 1-D array")
|
|
19
|
+
_validate_interval(start, end)
|
|
20
|
+
_validate_spacing(min_spacing)
|
|
21
|
+
interval_count = values.size
|
|
22
|
+
position_count = max(interval_count - 1, 0)
|
|
23
|
+
if position_count == 0:
|
|
24
|
+
return np.empty(0, dtype=float)
|
|
25
|
+
span = end - start
|
|
26
|
+
reserved = max(position_count - 1, 0) * min_spacing
|
|
27
|
+
if reserved >= span:
|
|
28
|
+
raise ValueError("interval too small for the requested positions and spacing")
|
|
29
|
+
weights = np.exp(np.maximum(values - values.max(), -27.0))
|
|
30
|
+
fractions = weights / weights.sum()
|
|
31
|
+
gaps = fractions * (span - reserved)
|
|
32
|
+
gaps[1:-1] += min_spacing
|
|
33
|
+
return start + np.cumsum(gaps[:-1])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ordered_positions_to_logits(
|
|
37
|
+
positions: np.ndarray,
|
|
38
|
+
*,
|
|
39
|
+
min_spacing: float = 0.0,
|
|
40
|
+
start: float = 0.0,
|
|
41
|
+
end: float = 1.0,
|
|
42
|
+
) -> np.ndarray:
|
|
43
|
+
"""Encode ordered positions as interval logits, up to a constant offset."""
|
|
44
|
+
values = np.asarray(positions, dtype=float)
|
|
45
|
+
if values.ndim != 1 or not np.isfinite(values).all():
|
|
46
|
+
raise ValueError("positions must be a finite 1-D array")
|
|
47
|
+
_validate_interval(start, end)
|
|
48
|
+
_validate_spacing(min_spacing)
|
|
49
|
+
if values.size == 0:
|
|
50
|
+
return np.empty(0, dtype=float)
|
|
51
|
+
if np.any(np.diff(values) <= 0.0) or values[0] <= start or values[-1] >= end:
|
|
52
|
+
raise ValueError("positions must be strictly ordered inside the interval")
|
|
53
|
+
gaps = np.diff(np.concatenate(([start], values, [end])))
|
|
54
|
+
if np.any(gaps[1:-1] < min_spacing - 1e-12):
|
|
55
|
+
raise ValueError(f"positions must respect minimum spacing {min_spacing}")
|
|
56
|
+
available = (end - start) - max(values.size - 1, 0) * min_spacing
|
|
57
|
+
fractions = gaps.copy()
|
|
58
|
+
fractions[1:-1] -= min_spacing
|
|
59
|
+
fractions = np.clip(fractions / available, 1e-12, None)
|
|
60
|
+
return np.log(fractions)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decode_interval(param: float, *, low: float, high: float) -> float:
|
|
64
|
+
"""Map an unconstrained scalar into the open interval ``(low, high)``."""
|
|
65
|
+
_validate_interval(low, high)
|
|
66
|
+
value = float(param)
|
|
67
|
+
if not np.isfinite(value):
|
|
68
|
+
raise ValueError("parameter must be finite")
|
|
69
|
+
if value >= 0.0:
|
|
70
|
+
logistic = 1.0 / (1.0 + np.exp(-value))
|
|
71
|
+
else:
|
|
72
|
+
exponential = np.exp(value)
|
|
73
|
+
logistic = exponential / (1.0 + exponential)
|
|
74
|
+
return float(low + (high - low) * logistic)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encode_interval(value: float, *, low: float, high: float) -> float:
|
|
78
|
+
"""Map an interval value back to its unconstrained logistic parameter."""
|
|
79
|
+
_validate_interval(low, high)
|
|
80
|
+
numeric = float(value)
|
|
81
|
+
if not np.isfinite(numeric) or not low <= numeric <= high:
|
|
82
|
+
raise ValueError("value must be finite and inside the interval")
|
|
83
|
+
fraction = np.clip((numeric - low) / (high - low), 1e-12, 1.0 - 1e-12)
|
|
84
|
+
return float(np.log(fraction / (1.0 - fraction)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def decode_positive(param: float) -> float:
|
|
88
|
+
"""Decode a real value as a positive scalar."""
|
|
89
|
+
value = float(param)
|
|
90
|
+
if not np.isfinite(value):
|
|
91
|
+
raise ValueError("parameter must be finite")
|
|
92
|
+
return float(np.exp(np.clip(value, -50.0, 50.0)))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def encode_positive(value: float) -> float:
|
|
96
|
+
"""Encode a positive scalar as its logarithm."""
|
|
97
|
+
numeric = float(value)
|
|
98
|
+
if not np.isfinite(numeric) or numeric <= 0.0:
|
|
99
|
+
raise ValueError("value must be positive and finite")
|
|
100
|
+
return float(np.log(numeric))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _validate_interval(low: float, high: float) -> None:
|
|
104
|
+
if not np.isfinite([low, high]).all() or not low < high:
|
|
105
|
+
raise ValueError("interval bounds must be finite and increasing")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_spacing(min_spacing: float) -> None:
|
|
109
|
+
if not np.isfinite(min_spacing) or min_spacing < 0.0:
|
|
110
|
+
raise ValueError("min_spacing must be finite and nonnegative")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class SplineEndpointPolicy:
|
|
9
|
+
"""Endpoint constraints used by spline fitting, not by spline geometry."""
|
|
10
|
+
|
|
11
|
+
start: Literal["locked", "free", "anchored"] = "locked"
|
|
12
|
+
end: Literal["locked", "free", "anchored"] = "locked"
|
|
13
|
+
|
|
14
|
+
def __post_init__(self) -> None:
|
|
15
|
+
allowed = {"locked", "free", "anchored"}
|
|
16
|
+
if self.start not in allowed or self.end not in allowed:
|
|
17
|
+
raise ValueError("endpoint policy must be locked, free, or anchored")
|