pacejka 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.
- pacejka/__init__.py +7 -0
- pacejka/py.typed +0 -0
- pacejka/tire.py +455 -0
- pacejka-0.1.0.dist-info/METADATA +253 -0
- pacejka-0.1.0.dist-info/RECORD +8 -0
- pacejka-0.1.0.dist-info/WHEEL +5 -0
- pacejka-0.1.0.dist-info/licenses/LICENSE +21 -0
- pacejka-0.1.0.dist-info/top_level.txt +1 -0
pacejka/__init__.py
ADDED
pacejka/py.typed
ADDED
|
File without changes
|
pacejka/tire.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Tire force model based on the pacejka "magic formula"."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional, Tuple, Union
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.typing import ArrayLike
|
|
8
|
+
|
|
9
|
+
# Conversion factor used to turn a stiffness per degree into a stiffness per radian
|
|
10
|
+
DEG_PER_RAD = 180.0 / np.pi
|
|
11
|
+
|
|
12
|
+
# Inputs can be single numbers or anything array-like; results take the shape of the inputs
|
|
13
|
+
FloatOrArray = Union[float, np.ndarray]
|
|
14
|
+
|
|
15
|
+
# Plain Python/numpy floats take the fast scalar path; anything else takes the array path
|
|
16
|
+
_SCALAR = (int, float)
|
|
17
|
+
|
|
18
|
+
# Accepted values for the ``alpha_unit`` argument. Left as None, the unit is detected.
|
|
19
|
+
ALPHA_UNITS = ("rad", "deg")
|
|
20
|
+
|
|
21
|
+
# When ``alpha_unit`` is not given, slip angles are assumed to be radians unless any value
|
|
22
|
+
# has a magnitude beyond this limit (pi/2 rad = 90 deg, where a tire is no longer rolling
|
|
23
|
+
# forward). The whole input is then treated as degrees.
|
|
24
|
+
ALPHA_RADIAN_LIMIT = np.pi / 2
|
|
25
|
+
|
|
26
|
+
# Default magic formula shape (C) and curvature (E) factors
|
|
27
|
+
DEFAULT_C = 1.4
|
|
28
|
+
DEFAULT_E_LONGITUDINAL = 0.1
|
|
29
|
+
DEFAULT_E_LATERAL = -0.1
|
|
30
|
+
|
|
31
|
+
# Defaults used when building a tire from macro properties
|
|
32
|
+
DEFAULT_LOAD_SENSITIVITY = 0.9
|
|
33
|
+
DEFAULT_REFERENCE_LOAD = 150
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class AxisShape:
|
|
38
|
+
"""
|
|
39
|
+
Fixed magic formula settings for one axis (longitudinal or lateral).
|
|
40
|
+
|
|
41
|
+
These describe the tire itself. They are set once at construction and
|
|
42
|
+
cannot be modified afterwards.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
c: Shape factor.
|
|
46
|
+
e: Curvature factor.
|
|
47
|
+
b: Stiffness factor. Only set for ``constant_b`` tires; otherwise B
|
|
48
|
+
depends on the load and is computed per request.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
c: float
|
|
52
|
+
e: float
|
|
53
|
+
b: Optional[float] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class MagicFormulaCoefficients:
|
|
58
|
+
"""
|
|
59
|
+
The complete set of magic formula coefficients for one axis at one load.
|
|
60
|
+
|
|
61
|
+
Created fresh for every request by ``Tire.coefficients_at`` and never
|
|
62
|
+
stored on the tire, so concurrent calls cannot interfere with each other.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
b: FloatOrArray
|
|
66
|
+
c: float
|
|
67
|
+
d: FloatOrArray
|
|
68
|
+
e: float
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Tire:
|
|
72
|
+
"""
|
|
73
|
+
Holds tire parameters and computes tire forces for the applied conditions.
|
|
74
|
+
|
|
75
|
+
The fixed magic formula settings for each axis live in ``longitudinal`` and
|
|
76
|
+
``lateral`` (both ``AxisShape``). The load-dependent coefficients (D, and B
|
|
77
|
+
unless ``constant_b`` is set) are computed on demand by ``coefficients_at``
|
|
78
|
+
and are never stored on the tire.
|
|
79
|
+
|
|
80
|
+
The force methods accept single numbers or numpy arrays. Array inputs are
|
|
81
|
+
broadcast together by numpy, so for example a column of loads and a row of
|
|
82
|
+
slip angles produce a grid of forces.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
name (str): Tire name (intended to be the compound name).
|
|
86
|
+
mu_peak (float): Peak coefficient of friction.
|
|
87
|
+
cornering_stiffness_per_rad (float): Force per radian of slip angle (alpha)
|
|
88
|
+
at small angles, i.e. the slope of the force vs. angle curve. The
|
|
89
|
+
constructor only accepts radians; use ``from_macro_properties`` to
|
|
90
|
+
supply a per-degree value.
|
|
91
|
+
longitudinal_stiffness (float): Force per unit of longitudinal slip (kappa).
|
|
92
|
+
load_sensitivity_n (float): Non-linearity of peak friction as load increases.
|
|
93
|
+
f_z0 (float): Typical (reference) load, used in the load sensitivity calculations.
|
|
94
|
+
constant_b (bool): If True, B is supplied by the user and never recomputed.
|
|
95
|
+
longitudinal (AxisShape): Fixed C, E (and B if constant) for the longitudinal axis.
|
|
96
|
+
lateral (AxisShape): Fixed C, E (and B if constant) for the lateral axis.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
name: str,
|
|
102
|
+
mu_peak: float,
|
|
103
|
+
cornering_stiffness_per_rad: Optional[float] = None,
|
|
104
|
+
longitudinal_stiffness: Optional[float] = None,
|
|
105
|
+
load_sensitivity_n: Optional[float] = None,
|
|
106
|
+
f_z0: Optional[float] = None,
|
|
107
|
+
b_x: Optional[float] = None,
|
|
108
|
+
c_x: float = DEFAULT_C,
|
|
109
|
+
e_x: float = DEFAULT_E_LONGITUDINAL,
|
|
110
|
+
b_y: Optional[float] = None,
|
|
111
|
+
c_y: float = DEFAULT_C,
|
|
112
|
+
e_y: float = DEFAULT_E_LATERAL,
|
|
113
|
+
constant_b: bool = False,
|
|
114
|
+
):
|
|
115
|
+
self.name = name
|
|
116
|
+
self.mu_peak = mu_peak
|
|
117
|
+
self.cornering_stiffness_per_rad = cornering_stiffness_per_rad
|
|
118
|
+
self.longitudinal_stiffness = longitudinal_stiffness
|
|
119
|
+
self.load_sensitivity_n = load_sensitivity_n
|
|
120
|
+
self.f_z0 = f_z0
|
|
121
|
+
self.constant_b = constant_b
|
|
122
|
+
|
|
123
|
+
# Only the fixed settings are stored. D (and B unless constant_b is set)
|
|
124
|
+
# depend on the load, so they are computed per request instead.
|
|
125
|
+
self.longitudinal = AxisShape(c=c_x, e=e_x, b=b_x)
|
|
126
|
+
self.lateral = AxisShape(c=c_y, e=e_y, b=b_y)
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
# Alternate constructors
|
|
130
|
+
# ------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_constants(
|
|
134
|
+
cls,
|
|
135
|
+
compound_name: str,
|
|
136
|
+
mu_base: float,
|
|
137
|
+
b_x: float,
|
|
138
|
+
c_x: float,
|
|
139
|
+
e_x: float,
|
|
140
|
+
b_y: Optional[float] = None,
|
|
141
|
+
c_y: Optional[float] = None,
|
|
142
|
+
e_y: Optional[float] = None,
|
|
143
|
+
) -> "Tire":
|
|
144
|
+
"""Build a tire from fixed B, C, E constants. Lateral values default to the longitudinal ones."""
|
|
145
|
+
return cls(
|
|
146
|
+
compound_name,
|
|
147
|
+
mu_base,
|
|
148
|
+
b_x=b_x,
|
|
149
|
+
c_x=c_x,
|
|
150
|
+
e_x=e_x,
|
|
151
|
+
b_y=b_x if b_y is None else b_y,
|
|
152
|
+
c_y=c_x if c_y is None else c_y,
|
|
153
|
+
e_y=e_x if e_y is None else e_y,
|
|
154
|
+
constant_b=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_macro_properties(
|
|
159
|
+
cls,
|
|
160
|
+
compound_name: str,
|
|
161
|
+
mu_base: float,
|
|
162
|
+
cornering_stiffness_per_deg: Optional[float] = None,
|
|
163
|
+
longitudinal_stiffness: Optional[float] = None,
|
|
164
|
+
load_sensitivity_n: float = DEFAULT_LOAD_SENSITIVITY,
|
|
165
|
+
f_z0: float = DEFAULT_REFERENCE_LOAD,
|
|
166
|
+
cornering_stiffness_per_rad: Optional[float] = None,
|
|
167
|
+
) -> "Tire":
|
|
168
|
+
"""
|
|
169
|
+
Build a tire from measurable stiffnesses; B is derived from them at each load.
|
|
170
|
+
|
|
171
|
+
Cornering stiffness can be given per degree or per radian (exactly one of
|
|
172
|
+
the two). Longitudinal stiffness is required.
|
|
173
|
+
"""
|
|
174
|
+
if cornering_stiffness_per_deg is None and cornering_stiffness_per_rad is None:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"Provide cornering stiffness per degree or per radian."
|
|
177
|
+
)
|
|
178
|
+
if longitudinal_stiffness is None:
|
|
179
|
+
raise ValueError("longitudinal_stiffness is required.")
|
|
180
|
+
|
|
181
|
+
if cornering_stiffness_per_deg is not None and cornering_stiffness_per_rad is not None:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
"Provide cornering stiffness per degree or per radian, not both."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# The constructor works in radians only, so convert degrees here
|
|
187
|
+
if cornering_stiffness_per_rad is None:
|
|
188
|
+
cornering_stiffness_per_rad = cornering_stiffness_per_deg * DEG_PER_RAD
|
|
189
|
+
|
|
190
|
+
return cls(
|
|
191
|
+
compound_name,
|
|
192
|
+
mu_base,
|
|
193
|
+
cornering_stiffness_per_rad=cornering_stiffness_per_rad,
|
|
194
|
+
longitudinal_stiffness=longitudinal_stiffness,
|
|
195
|
+
load_sensitivity_n=load_sensitivity_n,
|
|
196
|
+
f_z0=f_z0,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Public force calculations
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def calculate_forces(
|
|
204
|
+
self,
|
|
205
|
+
f_z: ArrayLike,
|
|
206
|
+
alpha: ArrayLike,
|
|
207
|
+
kappa: ArrayLike,
|
|
208
|
+
friction_circle: bool = True,
|
|
209
|
+
alpha_unit: Optional[str] = None,
|
|
210
|
+
) -> Tuple[FloatOrArray, FloatOrArray]:
|
|
211
|
+
"""
|
|
212
|
+
Calculate tire forces for a vertical load, slip angle and slip ratio.
|
|
213
|
+
|
|
214
|
+
Each of ``f_z``, ``alpha`` and ``kappa`` can be a single number or an
|
|
215
|
+
array, and arrays are broadcast together. With ``friction_circle=True`` both
|
|
216
|
+
forces have the broadcast shape of all three inputs. With
|
|
217
|
+
``friction_circle=False`` each force only has the shape of its own inputs
|
|
218
|
+
(lateral: ``f_z`` and ``alpha``; longitudinal: ``f_z`` and ``kappa``).
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
f_z: Vertical load on the tire.
|
|
222
|
+
alpha: Slip angle. See ``alpha_unit`` for the unit.
|
|
223
|
+
kappa: Longitudinal slip ratio.
|
|
224
|
+
friction_circle: If True, limit the combined force to the friction circle.
|
|
225
|
+
alpha_unit: ``"rad"`` or ``"deg"`` to state the unit of ``alpha`` explicitly.
|
|
226
|
+
If None, ``alpha`` is assumed to be radians unless any value has a
|
|
227
|
+
magnitude above ALPHA_RADIAN_LIMIT (pi/2), in which case all of
|
|
228
|
+
``alpha`` is treated as degrees.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
(lateral_force, longitudinal_force)
|
|
232
|
+
"""
|
|
233
|
+
longitudinal, lateral = self.coefficients_at(f_z)
|
|
234
|
+
|
|
235
|
+
lateral_force = self._magic_formula_core(
|
|
236
|
+
self._slip_angle_to_radians(alpha, alpha_unit), lateral
|
|
237
|
+
)
|
|
238
|
+
longitudinal_force = self._magic_formula_core(kappa, longitudinal)
|
|
239
|
+
|
|
240
|
+
if friction_circle:
|
|
241
|
+
longitudinal_force, lateral_force = self.friction_circle(
|
|
242
|
+
longitudinal_force, lateral_force, f_z
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
return lateral_force, longitudinal_force
|
|
246
|
+
|
|
247
|
+
def calculate_lateral_force(
|
|
248
|
+
self, f_z: ArrayLike, alpha: ArrayLike, alpha_unit: Optional[str] = None
|
|
249
|
+
) -> FloatOrArray:
|
|
250
|
+
"""
|
|
251
|
+
Calculate the pure lateral force (no friction circle) for a load and slip angle.
|
|
252
|
+
|
|
253
|
+
``f_z`` and ``alpha`` can be single numbers or arrays (broadcast together).
|
|
254
|
+
``alpha_unit`` is ``"rad"``, ``"deg"`` or None to detect the unit, exactly
|
|
255
|
+
as described in ``calculate_forces``.
|
|
256
|
+
"""
|
|
257
|
+
_, lateral = self.coefficients_at(f_z)
|
|
258
|
+
return self._magic_formula_core(self._slip_angle_to_radians(alpha, alpha_unit), lateral)
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
# Coefficient calculations
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def coefficients_at(
|
|
265
|
+
self, f_z: ArrayLike
|
|
266
|
+
) -> Tuple[MagicFormulaCoefficients, MagicFormulaCoefficients]:
|
|
267
|
+
"""
|
|
268
|
+
Build the full B, C, D, E coefficients for each axis at a vertical load.
|
|
269
|
+
|
|
270
|
+
Nothing is stored on the tire, so this has no side effects and is safe to
|
|
271
|
+
call from several threads at once.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
(longitudinal, lateral)
|
|
275
|
+
"""
|
|
276
|
+
d = self.calculate_d(f_z)
|
|
277
|
+
|
|
278
|
+
if self.constant_b:
|
|
279
|
+
b_longitudinal, b_lateral = self.longitudinal.b, self.lateral.b
|
|
280
|
+
else:
|
|
281
|
+
b_longitudinal, b_lateral = self.calculate_b(f_z, d)
|
|
282
|
+
|
|
283
|
+
longitudinal = MagicFormulaCoefficients(
|
|
284
|
+
b=b_longitudinal, c=self.longitudinal.c, d=d, e=self.longitudinal.e
|
|
285
|
+
)
|
|
286
|
+
lateral = MagicFormulaCoefficients(
|
|
287
|
+
b=b_lateral, c=self.lateral.c, d=d, e=self.lateral.e
|
|
288
|
+
)
|
|
289
|
+
return longitudinal, lateral
|
|
290
|
+
|
|
291
|
+
def calculate_d(self, f_z: ArrayLike) -> FloatOrArray:
|
|
292
|
+
"""
|
|
293
|
+
Return the peak factor D (peak force) at a vertical load.
|
|
294
|
+
|
|
295
|
+
Assumes the same coefficient of friction in both directions, so the
|
|
296
|
+
value applies to both axes.
|
|
297
|
+
|
|
298
|
+
A tire carrying no load (or lifted off the ground, f_z <= 0) can produce
|
|
299
|
+
no force. This is returned directly, because the load sensitivity term
|
|
300
|
+
raises zero to a negative power and cannot be evaluated there. For an
|
|
301
|
+
array of loads this is applied to each element separately.
|
|
302
|
+
|
|
303
|
+
If ``load_sensitivity_n`` is None, load sensitivity is skipped and the
|
|
304
|
+
friction coefficient is treated as constant (D = mu_peak * f_z).
|
|
305
|
+
"""
|
|
306
|
+
if isinstance(f_z, _SCALAR): # fast path for a single load
|
|
307
|
+
if f_z <= 0:
|
|
308
|
+
return 0.0
|
|
309
|
+
if self.load_sensitivity_n is None:
|
|
310
|
+
return self.mu_peak * f_z
|
|
311
|
+
return self.mu_peak * pow(self._load_ratio(f_z), self.load_sensitivity_n - 1) * f_z
|
|
312
|
+
|
|
313
|
+
f_z = np.asarray(f_z, dtype=float)
|
|
314
|
+
loaded = f_z > 0
|
|
315
|
+
# Swap unloaded points for 1.0 so the power below never sees 0 or a negative
|
|
316
|
+
safe_f_z = np.where(loaded, f_z, 1.0)
|
|
317
|
+
|
|
318
|
+
if self.load_sensitivity_n is None:
|
|
319
|
+
mu_effective = self.mu_peak
|
|
320
|
+
else:
|
|
321
|
+
# Peak friction falls off as load rises above the reference load
|
|
322
|
+
mu_effective = self.mu_peak * pow(
|
|
323
|
+
self._load_ratio(safe_f_z), self.load_sensitivity_n - 1
|
|
324
|
+
)
|
|
325
|
+
return np.where(loaded, mu_effective * safe_f_z, 0.0)[()]
|
|
326
|
+
|
|
327
|
+
def calculate_b(
|
|
328
|
+
self, f_z: ArrayLike, d: FloatOrArray
|
|
329
|
+
) -> Tuple[FloatOrArray, FloatOrArray]:
|
|
330
|
+
"""
|
|
331
|
+
Return the stiffness factor B for each axis, from the cornering and
|
|
332
|
+
longitudinal stiffness at a vertical load.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
f_z: Vertical load.
|
|
336
|
+
d: Peak factor D at this load.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
(b_longitudinal, b_lateral)
|
|
340
|
+
"""
|
|
341
|
+
# Unloaded points have d == 0, which already forces B to 0 below
|
|
342
|
+
if isinstance(f_z, _SCALAR):
|
|
343
|
+
safe_f_z = f_z if f_z > 0 else 1.0
|
|
344
|
+
else:
|
|
345
|
+
f_z = np.asarray(f_z, dtype=float)
|
|
346
|
+
safe_f_z = np.where(f_z > 0, f_z, 1.0)
|
|
347
|
+
load_scaling = self._load_ratio(safe_f_z) ** self.load_sensitivity_n
|
|
348
|
+
|
|
349
|
+
# Lateral: B needs the cornering stiffness per radian
|
|
350
|
+
cornering_stiffness_rad = self.cornering_stiffness_per_rad * load_scaling
|
|
351
|
+
b_lateral = self._stiffness_to_b(cornering_stiffness_rad, self.lateral.c, d)
|
|
352
|
+
|
|
353
|
+
# Longitudinal
|
|
354
|
+
longitudinal_stiffness = self.longitudinal_stiffness * load_scaling
|
|
355
|
+
b_longitudinal = self._stiffness_to_b(longitudinal_stiffness, self.longitudinal.c, d)
|
|
356
|
+
|
|
357
|
+
return b_longitudinal, b_lateral
|
|
358
|
+
|
|
359
|
+
def friction_circle(
|
|
360
|
+
self, fx_raw: ArrayLike, fy_raw: ArrayLike, f_z: ArrayLike
|
|
361
|
+
) -> Tuple[FloatOrArray, FloatOrArray]:
|
|
362
|
+
"""
|
|
363
|
+
Limit the combined force to the friction circle. The radius is the peak
|
|
364
|
+
force at this load, so it includes load sensitivity (same as D).
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
fx_raw: Unconstrained longitudinal force.
|
|
368
|
+
fy_raw: Unconstrained lateral force.
|
|
369
|
+
f_z: Vertical load.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
(fx_final, fy_final)
|
|
373
|
+
"""
|
|
374
|
+
if isinstance(fx_raw, _SCALAR) and isinstance(fy_raw, _SCALAR) and isinstance(f_z, _SCALAR):
|
|
375
|
+
f_max = self.calculate_d(f_z) # fast path for single values
|
|
376
|
+
magnitude = np.sqrt(fx_raw ** 2 + fy_raw ** 2)
|
|
377
|
+
if magnitude > f_max and magnitude > 0:
|
|
378
|
+
scale = f_max / magnitude
|
|
379
|
+
return fx_raw * scale, fy_raw * scale
|
|
380
|
+
return fx_raw, fy_raw
|
|
381
|
+
|
|
382
|
+
fx_raw = np.asarray(fx_raw, dtype=float)
|
|
383
|
+
fy_raw = np.asarray(fy_raw, dtype=float)
|
|
384
|
+
f_max = self.calculate_d(f_z)
|
|
385
|
+
magnitude = np.sqrt(fx_raw ** 2 + fy_raw ** 2)
|
|
386
|
+
|
|
387
|
+
# Where total demand exceeds physical grip, scale back to the circle boundary
|
|
388
|
+
over = (magnitude > f_max) & (magnitude > 0)
|
|
389
|
+
scale = np.where(over, f_max / np.where(over, magnitude, 1.0), 1.0)
|
|
390
|
+
return (fx_raw * scale)[()], (fy_raw * scale)[()]
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
# Internal helpers
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
def _load_ratio(self, f_z: FloatOrArray) -> FloatOrArray:
|
|
397
|
+
"""Vertical load relative to the reference load."""
|
|
398
|
+
return f_z / self.f_z0
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _slip_angle_to_radians(
|
|
402
|
+
alpha: ArrayLike, alpha_unit: Optional[str] = None
|
|
403
|
+
) -> FloatOrArray:
|
|
404
|
+
"""
|
|
405
|
+
Return the slip angle in radians.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
alpha: Slip angle, a single value or an array.
|
|
409
|
+
alpha_unit: ``"rad"`` (no conversion), ``"deg"`` (always convert), or
|
|
410
|
+
None to detect the unit. Detection makes one decision for the
|
|
411
|
+
whole input: if any value is beyond ALPHA_RADIAN_LIMIT, all of it
|
|
412
|
+
is treated as degrees, so a sweep is never half degrees, half radians.
|
|
413
|
+
"""
|
|
414
|
+
if alpha_unit is not None and alpha_unit not in ALPHA_UNITS:
|
|
415
|
+
raise ValueError(f"alpha_unit must be one of {ALPHA_UNITS} or None, got {alpha_unit!r}")
|
|
416
|
+
|
|
417
|
+
is_scalar = isinstance(alpha, _SCALAR)
|
|
418
|
+
if not is_scalar:
|
|
419
|
+
alpha = np.asarray(alpha, dtype=float)
|
|
420
|
+
|
|
421
|
+
if alpha_unit == "rad":
|
|
422
|
+
return alpha
|
|
423
|
+
if alpha_unit == "deg":
|
|
424
|
+
return np.radians(alpha)
|
|
425
|
+
|
|
426
|
+
exceeds_limit = abs(alpha) > ALPHA_RADIAN_LIMIT if is_scalar else np.any(
|
|
427
|
+
np.abs(alpha) > ALPHA_RADIAN_LIMIT
|
|
428
|
+
)
|
|
429
|
+
return np.radians(alpha) if exceeds_limit else alpha
|
|
430
|
+
|
|
431
|
+
@staticmethod
|
|
432
|
+
def _stiffness_to_b(stiffness: FloatOrArray, c: float, d: FloatOrArray) -> FloatOrArray:
|
|
433
|
+
"""Convert a stiffness into the magic formula B factor (B = stiffness / (C * D))."""
|
|
434
|
+
if isinstance(d, _SCALAR): # fast path for a single load
|
|
435
|
+
return stiffness / (c * d) if d > 0 else 0
|
|
436
|
+
d = np.asarray(d, dtype=float)
|
|
437
|
+
positive = d > 0
|
|
438
|
+
safe_d = np.where(positive, d, 1.0)
|
|
439
|
+
return np.where(positive, stiffness / (c * safe_d), 0.0)[()]
|
|
440
|
+
|
|
441
|
+
@staticmethod
|
|
442
|
+
def _magic_formula_core(x_input: ArrayLike, coeffs: MagicFormulaCoefficients) -> FloatOrArray:
|
|
443
|
+
"""
|
|
444
|
+
Evaluate the magic formula: D * sin(C * arctan(Bx - E * (Bx - arctan(Bx)))).
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
x_input: Slip input (slip angle or slip ratio).
|
|
448
|
+
coeffs: The B, C, D and E coefficients for this axis and load.
|
|
449
|
+
"""
|
|
450
|
+
if not isinstance(x_input, _SCALAR):
|
|
451
|
+
x_input = np.asarray(x_input, dtype=float)
|
|
452
|
+
|
|
453
|
+
bx = coeffs.b * x_input
|
|
454
|
+
argument = coeffs.c * np.arctan(bx - coeffs.e * (bx - np.arctan(bx)))
|
|
455
|
+
return coeffs.d * np.sin(argument)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pacejka
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Calculate tire forces with the Pacejka magic formula
|
|
5
|
+
Author: 2023popn
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/2023popn/Pacejka
|
|
8
|
+
Project-URL: Issues, https://github.com/2023popn/Pacejka/issues
|
|
9
|
+
Keywords: tire,pacejka,magic formula,vehicle dynamics,motorsport
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Intended Audience :: Education
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: numpy>=1.20
|
|
25
|
+
Provides-Extra: plots
|
|
26
|
+
Requires-Dist: matplotlib>=3.5; extra == "plots"
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
29
|
+
Requires-Dist: matplotlib>=3.5; extra == "test"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# Pacejka
|
|
33
|
+
|
|
34
|
+
Calculate the forces a tire generates with the Pacejka "magic formula".
|
|
35
|
+
|
|
36
|
+
Describe a tire by its peak friction, cornering and longitudinal stiffness, and how its grip changes with load. Then ask for the lateral and longitudinal force at any vertical load, slip angle and slip ratio. Everything works on single numbers or on whole numpy arrays, so a full slip-angle sweep or a load-versus-slip grid is one call.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install pacejka
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The only dependency is numpy. To run the plotting tests too, install the `test` extra (see [Development](#development)).
|
|
45
|
+
|
|
46
|
+
The package is installed as `pacejka` and imported as `Pacejka`:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from pacejka import Tire
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from pacejka import Tire
|
|
56
|
+
|
|
57
|
+
tire = Tire.from_macro_properties(
|
|
58
|
+
"Hoosier R55A",
|
|
59
|
+
mu_base=1.6, # peak friction coefficient at the reference load
|
|
60
|
+
cornering_stiffness_per_deg=25.0, # lateral force per degree of slip angle (small angles)
|
|
61
|
+
longitudinal_stiffness=2000.0, # longitudinal force per unit slip ratio (small slip)
|
|
62
|
+
load_sensitivity_n=0.9, # how much grip per unit load falls as load rises
|
|
63
|
+
f_z0=150.0, # reference load the stiffnesses are quoted at
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
fy, fx = tire.calculate_forces(f_z=150.0, alpha=4.0, kappa=0.05, alpha_unit="deg")
|
|
67
|
+
print(f"lateral = {fy:.1f}, longitudinal = {fx:.1f}")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
lateral = 94.8, longitudinal = 94.3
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`calculate_forces` returns `(lateral_force, longitudinal_force)`. Forces come out in whatever unit you use for load, so pounds in gives pounds-force out and newtons in gives newtons out.
|
|
75
|
+
|
|
76
|
+
If you only need the lateral force:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
fy = tire.calculate_lateral_force(150.0, 4.0, alpha_unit="deg")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Units
|
|
83
|
+
|
|
84
|
+
| Input | Unit |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `f_z` (vertical load) | any force unit; forces come back in the same unit |
|
|
87
|
+
| `alpha` (slip angle) | radians or degrees, see below |
|
|
88
|
+
| `kappa` (slip ratio) | dimensionless, `1.0` is 100 % slip |
|
|
89
|
+
| `cornering_stiffness_per_deg` / `_per_rad` | force per degree / radian of slip angle |
|
|
90
|
+
| `longitudinal_stiffness` | force per unit slip ratio |
|
|
91
|
+
|
|
92
|
+
### Slip angle: radians or degrees
|
|
93
|
+
|
|
94
|
+
State the unit of `alpha` explicitly with `alpha_unit="rad"` or `alpha_unit="deg"`. This is the safest option.
|
|
95
|
+
|
|
96
|
+
If you leave `alpha_unit` out, the unit is detected: the angle is taken to be radians unless any value has a magnitude above π/2 (about 1.57), in which case the **whole** input is treated as degrees. A sweep from −15 to 15 is therefore read correctly as degrees, but small angles in degrees are not (an angle of `0.5` looks like radians). When in doubt, pass `alpha_unit`.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
tire.calculate_lateral_force(150.0, 4.0) # detected: 4.0 is degrees
|
|
100
|
+
tire.calculate_lateral_force(150.0, 0.5) # detected: 0.5 is radians -> 239.8
|
|
101
|
+
tire.calculate_lateral_force(150.0, 0.5, alpha_unit="deg") # explicitly degrees -> 12.5
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Cornering stiffness: per degree or per radian
|
|
105
|
+
|
|
106
|
+
`from_macro_properties` takes either `cornering_stiffness_per_deg` or `cornering_stiffness_per_rad` (exactly one). The `Tire` constructor itself works in radians only.
|
|
107
|
+
|
|
108
|
+
## Sweeps and grids with arrays
|
|
109
|
+
|
|
110
|
+
Pass arrays for any of `f_z`, `alpha` and `kappa`. numpy broadcasting combines them.
|
|
111
|
+
|
|
112
|
+
A slip-angle sweep at one load:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
import numpy as np
|
|
116
|
+
|
|
117
|
+
alpha = np.linspace(-15, 15, 7)
|
|
118
|
+
fy = tire.calculate_lateral_force(150.0, alpha, alpha_unit="deg")
|
|
119
|
+
print(np.round(fy, 1))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
[-223.1 -188.7 -115.2 0. 115.2 188.7 223.1]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A grid with one row per load and one column per slip angle:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
loads = np.array([[100.0], [150.0], [200.0]]) # a column
|
|
130
|
+
alpha = np.array([2.0, 5.0, 10.0]) # a row
|
|
131
|
+
grid = tire.calculate_lateral_force(loads, alpha, alpha_unit="deg")
|
|
132
|
+
print(grid.shape)
|
|
133
|
+
print(np.round(grid, 1))
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
(3, 3)
|
|
138
|
+
[[ 34.2 80. 131. ]
|
|
139
|
+
[ 49.3 115.2 188.7]
|
|
140
|
+
[ 63.9 149.2 244.5]]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Plotting a sweep with matplotlib:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
import matplotlib.pyplot as plt
|
|
147
|
+
|
|
148
|
+
alpha = np.linspace(-30, 30, 400)
|
|
149
|
+
for f_z in (50, 100, 150, 200, 250):
|
|
150
|
+
plt.plot(alpha, tire.calculate_lateral_force(f_z, alpha, alpha_unit="deg"), label=f"{f_z}")
|
|
151
|
+
plt.xlabel("Slip angle (deg)")
|
|
152
|
+
plt.ylabel("Lateral force")
|
|
153
|
+
plt.legend()
|
|
154
|
+
plt.show()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A scalar in gives a scalar out. Lists work as well as arrays.
|
|
158
|
+
|
|
159
|
+
> When `friction_circle=False`, each force has the shape of its own inputs (lateral from `f_z` and `alpha`, longitudinal from `f_z` and `kappa`). With the friction circle on (the default), both forces have the full broadcast shape.
|
|
160
|
+
|
|
161
|
+
## The friction circle
|
|
162
|
+
|
|
163
|
+
By default, `calculate_forces` limits the combined force so it cannot exceed the tire's peak force at that load. If the lateral and longitudinal forces together are too large, both are scaled down by the same factor, which keeps their direction.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
for circle in (False, True):
|
|
167
|
+
fy, fx = tire.calculate_forces(150.0, 8.0, 0.3, friction_circle=circle, alpha_unit="deg")
|
|
168
|
+
print(f"friction_circle={circle}: fy={fy:.1f} fx={fx:.1f} total={np.hypot(fx, fy):.1f}")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
friction_circle=False: fy=164.9 fx=238.5 total=290.0
|
|
173
|
+
friction_circle=True: fy=136.5 fx=197.4 total=240.0
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The limit here is 240, which is the peak force at this load (`tire.calculate_d(150.0)`). Pass `friction_circle=False` for the pure, independent lateral and longitudinal forces.
|
|
177
|
+
|
|
178
|
+
## Building a tire
|
|
179
|
+
|
|
180
|
+
**From measurable properties** (most common). B is derived from your stiffnesses at each load, and grip changes with load:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
Tire.from_macro_properties("name", mu_base=1.6, cornering_stiffness_per_deg=25.0,
|
|
184
|
+
longitudinal_stiffness=2000.0, load_sensitivity_n=0.9, f_z0=150.0)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`load_sensitivity_n` of `1.0` means grip per unit load is constant. Lower values mean it falls as load increases (`0.9` is roughly a 10 % drop per doubling of load). `f_z0` is the load at which the stiffnesses and `mu_base` apply.
|
|
188
|
+
|
|
189
|
+
**From fixed magic formula constants.** You supply B, C and E directly, and there is no load sensitivity (peak force is `mu_base * f_z`). Lateral values default to the longitudinal ones if you leave them out:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
fixed_tire = Tire.from_constants("Fixed", mu_base=1.5, b_x=8.0, c_x=1.4, e_x=0.1)
|
|
193
|
+
fy, fx = fixed_tire.calculate_forces(200.0, 0.1, 0.05, alpha_unit="rad")
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Directly** with `Tire(...)`, which takes cornering stiffness per radian and the other values by keyword.
|
|
197
|
+
|
|
198
|
+
## Looking at the coefficients
|
|
199
|
+
|
|
200
|
+
`coefficients_at(f_z)` returns the full set of magic formula coefficients for each axis at a given load, as `(longitudinal, lateral)`:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
longitudinal, lateral = tire.coefficients_at(150.0)
|
|
204
|
+
print(lateral)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
MagicFormulaCoefficients(b=4.263078832818626, c=1.4, d=240.0, e=-0.1)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
These are immutable and freshly built on every call. A `Tire` never changes when you ask it for forces, so one tire can safely be shared between threads. The fixed settings that belong to the tire are on `tire.longitudinal` and `tire.lateral`, for example `AxisShape(c=1.4, e=-0.1, b=None)`.
|
|
212
|
+
|
|
213
|
+
## How it works
|
|
214
|
+
|
|
215
|
+
For each axis, with `x` the slip angle in radians (lateral) or the slip ratio (longitudinal):
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
F = D * sin( C * arctan( B*x - E * (B*x - arctan(B*x)) ) )
|
|
219
|
+
|
|
220
|
+
D = mu_peak * (Fz / Fz0) ** (n - 1) * Fz peak force at load Fz
|
|
221
|
+
B = stiffness * (Fz / Fz0) ** n / (C * D) from the small-slip stiffness
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`C` (shape) and `E` (curvature) default to 1.4 and 0.1 (longitudinal) or -0.1 (lateral). D is the same for both axes. A load of zero or less produces no force.
|
|
225
|
+
|
|
226
|
+
## Limitations
|
|
227
|
+
|
|
228
|
+
- These are the pure-slip magic formulas combined with a friction circle, not the full combined-slip Pacejka formulation. Lateral force does not depend on slip ratio except through the friction circle limit.
|
|
229
|
+
- No camber, inflation pressure, speed, temperature or aligning moment.
|
|
230
|
+
- The effective friction coefficient follows a power law in load, so it grows without bound as load approaches zero. Peak force still goes to zero.
|
|
231
|
+
- Tire properties are your inputs. The package does not ship measured tire data, so check your coefficients against your own data.
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
git clone https://github.com/2023popn/Pacejka
|
|
237
|
+
cd pacejka
|
|
238
|
+
pip install -e ".[test]"
|
|
239
|
+
pytest
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The tests include **sweeps**: plots of lateral force against slip angle, longitudinal force against slip ratio, load sensitivity, the friction circle, and a load-versus-slip grid. They are saved as PNG files in `sweep_output/` (or the folder given by `--sweep-dir`) so you can look at the curves, and each also checks the shape of what it plots.
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
pytest --sweep-dir=plots # save the sweep plots somewhere else
|
|
246
|
+
pytest -m "not sweep" # skip the plotting tests
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The sweep tests need matplotlib and are skipped if it is not installed.
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pacejka/__init__.py,sha256=5D0gvGtndphDnDbPp0UgroMv8q6p5w3goOAPh8xGP_A,232
|
|
2
|
+
pacejka/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
pacejka/tire.py,sha256=g9SBcTzkEzqWYDVg-qv-A_9PfB900HsI3shY9FHsyxQ,17912
|
|
4
|
+
pacejka-0.1.0.dist-info/licenses/LICENSE,sha256=K3ZkJrIMYW43Wn366xIcofxxTAFfatD6Y7jLbd0VU1c,1065
|
|
5
|
+
pacejka-0.1.0.dist-info/METADATA,sha256=zQNp8EK_WLbB3G6m_lsF-d56XOVho_alDOfD3H75dA0,9934
|
|
6
|
+
pacejka-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
pacejka-0.1.0.dist-info/top_level.txt,sha256=3Moz-Ykr26su8rdF_cpTMB7XLxUQ8c2vP4DM0CStqRg,8
|
|
8
|
+
pacejka-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 2023popn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pacejka
|