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 ADDED
@@ -0,0 +1,7 @@
1
+ """pacejka: tire force calculations using the pacejka magic formula."""
2
+
3
+ from .tire import AxisShape, MagicFormulaCoefficients, Tire
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["AxisShape", "MagicFormulaCoefficients", "Tire", "__version__"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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