pacejka 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- pacejka-0.1.0/MANIFEST.in +4 -0
- pacejka-0.1.0/PKG-INFO +253 -0
- pacejka-0.1.0/README.md +222 -0
- pacejka-0.1.0/pyproject.toml +52 -0
- pacejka-0.1.0/setup.cfg +4 -0
- pacejka-0.1.0/src/pacejka/__init__.py +7 -0
- pacejka-0.1.0/src/pacejka/py.typed +0 -0
- pacejka-0.1.0/src/pacejka/tire.py +455 -0
- pacejka-0.1.0/src/pacejka.egg-info/PKG-INFO +253 -0
- pacejka-0.1.0/src/pacejka.egg-info/SOURCES.txt +15 -0
- pacejka-0.1.0/src/pacejka.egg-info/dependency_links.txt +1 -0
- pacejka-0.1.0/src/pacejka.egg-info/requires.txt +8 -0
- pacejka-0.1.0/src/pacejka.egg-info/top_level.txt +1 -0
- pacejka-0.1.0/tests/conftest.py +99 -0
- pacejka-0.1.0/tests/test_sweeps.py +163 -0
- pacejka-0.1.0/tests/test_tire.py +384 -0
pacejka-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pacejka-0.1.0/PKG-INFO
ADDED
|
@@ -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`.
|
pacejka-0.1.0/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Pacejka
|
|
2
|
+
|
|
3
|
+
Calculate the forces a tire generates with the Pacejka "magic formula".
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install pacejka
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The only dependency is numpy. To run the plotting tests too, install the `test` extra (see [Development](#development)).
|
|
14
|
+
|
|
15
|
+
The package is installed as `pacejka` and imported as `Pacejka`:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from pacejka import Tire
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from pacejka import Tire
|
|
25
|
+
|
|
26
|
+
tire = Tire.from_macro_properties(
|
|
27
|
+
"Hoosier R55A",
|
|
28
|
+
mu_base=1.6, # peak friction coefficient at the reference load
|
|
29
|
+
cornering_stiffness_per_deg=25.0, # lateral force per degree of slip angle (small angles)
|
|
30
|
+
longitudinal_stiffness=2000.0, # longitudinal force per unit slip ratio (small slip)
|
|
31
|
+
load_sensitivity_n=0.9, # how much grip per unit load falls as load rises
|
|
32
|
+
f_z0=150.0, # reference load the stiffnesses are quoted at
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
fy, fx = tire.calculate_forces(f_z=150.0, alpha=4.0, kappa=0.05, alpha_unit="deg")
|
|
36
|
+
print(f"lateral = {fy:.1f}, longitudinal = {fx:.1f}")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
lateral = 94.8, longitudinal = 94.3
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`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.
|
|
44
|
+
|
|
45
|
+
If you only need the lateral force:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
fy = tire.calculate_lateral_force(150.0, 4.0, alpha_unit="deg")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Units
|
|
52
|
+
|
|
53
|
+
| Input | Unit |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `f_z` (vertical load) | any force unit; forces come back in the same unit |
|
|
56
|
+
| `alpha` (slip angle) | radians or degrees, see below |
|
|
57
|
+
| `kappa` (slip ratio) | dimensionless, `1.0` is 100 % slip |
|
|
58
|
+
| `cornering_stiffness_per_deg` / `_per_rad` | force per degree / radian of slip angle |
|
|
59
|
+
| `longitudinal_stiffness` | force per unit slip ratio |
|
|
60
|
+
|
|
61
|
+
### Slip angle: radians or degrees
|
|
62
|
+
|
|
63
|
+
State the unit of `alpha` explicitly with `alpha_unit="rad"` or `alpha_unit="deg"`. This is the safest option.
|
|
64
|
+
|
|
65
|
+
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`.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
tire.calculate_lateral_force(150.0, 4.0) # detected: 4.0 is degrees
|
|
69
|
+
tire.calculate_lateral_force(150.0, 0.5) # detected: 0.5 is radians -> 239.8
|
|
70
|
+
tire.calculate_lateral_force(150.0, 0.5, alpha_unit="deg") # explicitly degrees -> 12.5
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Cornering stiffness: per degree or per radian
|
|
74
|
+
|
|
75
|
+
`from_macro_properties` takes either `cornering_stiffness_per_deg` or `cornering_stiffness_per_rad` (exactly one). The `Tire` constructor itself works in radians only.
|
|
76
|
+
|
|
77
|
+
## Sweeps and grids with arrays
|
|
78
|
+
|
|
79
|
+
Pass arrays for any of `f_z`, `alpha` and `kappa`. numpy broadcasting combines them.
|
|
80
|
+
|
|
81
|
+
A slip-angle sweep at one load:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import numpy as np
|
|
85
|
+
|
|
86
|
+
alpha = np.linspace(-15, 15, 7)
|
|
87
|
+
fy = tire.calculate_lateral_force(150.0, alpha, alpha_unit="deg")
|
|
88
|
+
print(np.round(fy, 1))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
[-223.1 -188.7 -115.2 0. 115.2 188.7 223.1]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A grid with one row per load and one column per slip angle:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
loads = np.array([[100.0], [150.0], [200.0]]) # a column
|
|
99
|
+
alpha = np.array([2.0, 5.0, 10.0]) # a row
|
|
100
|
+
grid = tire.calculate_lateral_force(loads, alpha, alpha_unit="deg")
|
|
101
|
+
print(grid.shape)
|
|
102
|
+
print(np.round(grid, 1))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
(3, 3)
|
|
107
|
+
[[ 34.2 80. 131. ]
|
|
108
|
+
[ 49.3 115.2 188.7]
|
|
109
|
+
[ 63.9 149.2 244.5]]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Plotting a sweep with matplotlib:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
import matplotlib.pyplot as plt
|
|
116
|
+
|
|
117
|
+
alpha = np.linspace(-30, 30, 400)
|
|
118
|
+
for f_z in (50, 100, 150, 200, 250):
|
|
119
|
+
plt.plot(alpha, tire.calculate_lateral_force(f_z, alpha, alpha_unit="deg"), label=f"{f_z}")
|
|
120
|
+
plt.xlabel("Slip angle (deg)")
|
|
121
|
+
plt.ylabel("Lateral force")
|
|
122
|
+
plt.legend()
|
|
123
|
+
plt.show()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A scalar in gives a scalar out. Lists work as well as arrays.
|
|
127
|
+
|
|
128
|
+
> 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.
|
|
129
|
+
|
|
130
|
+
## The friction circle
|
|
131
|
+
|
|
132
|
+
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.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
for circle in (False, True):
|
|
136
|
+
fy, fx = tire.calculate_forces(150.0, 8.0, 0.3, friction_circle=circle, alpha_unit="deg")
|
|
137
|
+
print(f"friction_circle={circle}: fy={fy:.1f} fx={fx:.1f} total={np.hypot(fx, fy):.1f}")
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
friction_circle=False: fy=164.9 fx=238.5 total=290.0
|
|
142
|
+
friction_circle=True: fy=136.5 fx=197.4 total=240.0
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
## Building a tire
|
|
148
|
+
|
|
149
|
+
**From measurable properties** (most common). B is derived from your stiffnesses at each load, and grip changes with load:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
Tire.from_macro_properties("name", mu_base=1.6, cornering_stiffness_per_deg=25.0,
|
|
153
|
+
longitudinal_stiffness=2000.0, load_sensitivity_n=0.9, f_z0=150.0)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`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.
|
|
157
|
+
|
|
158
|
+
**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:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
fixed_tire = Tire.from_constants("Fixed", mu_base=1.5, b_x=8.0, c_x=1.4, e_x=0.1)
|
|
162
|
+
fy, fx = fixed_tire.calculate_forces(200.0, 0.1, 0.05, alpha_unit="rad")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Directly** with `Tire(...)`, which takes cornering stiffness per radian and the other values by keyword.
|
|
166
|
+
|
|
167
|
+
## Looking at the coefficients
|
|
168
|
+
|
|
169
|
+
`coefficients_at(f_z)` returns the full set of magic formula coefficients for each axis at a given load, as `(longitudinal, lateral)`:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
longitudinal, lateral = tire.coefficients_at(150.0)
|
|
173
|
+
print(lateral)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
MagicFormulaCoefficients(b=4.263078832818626, c=1.4, d=240.0, e=-0.1)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
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)`.
|
|
181
|
+
|
|
182
|
+
## How it works
|
|
183
|
+
|
|
184
|
+
For each axis, with `x` the slip angle in radians (lateral) or the slip ratio (longitudinal):
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
F = D * sin( C * arctan( B*x - E * (B*x - arctan(B*x)) ) )
|
|
188
|
+
|
|
189
|
+
D = mu_peak * (Fz / Fz0) ** (n - 1) * Fz peak force at load Fz
|
|
190
|
+
B = stiffness * (Fz / Fz0) ** n / (C * D) from the small-slip stiffness
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`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.
|
|
194
|
+
|
|
195
|
+
## Limitations
|
|
196
|
+
|
|
197
|
+
- 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.
|
|
198
|
+
- No camber, inflation pressure, speed, temperature or aligning moment.
|
|
199
|
+
- 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.
|
|
200
|
+
- Tire properties are your inputs. The package does not ship measured tire data, so check your coefficients against your own data.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
git clone https://github.com/2023popn/Pacejka
|
|
206
|
+
cd pacejka
|
|
207
|
+
pip install -e ".[test]"
|
|
208
|
+
pytest
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
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.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
pytest --sweep-dir=plots # save the sweep plots somewhere else
|
|
215
|
+
pytest -m "not sweep" # skip the plotting tests
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The sweep tests need matplotlib and are skipped if it is not installed.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pacejka"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Calculate tire forces with the Pacejka magic formula"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "2023popn" }]
|
|
14
|
+
keywords = ["tire", "pacejka", "magic formula", "vehicle dynamics", "motorsport"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Intended Audience :: Education",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = ["numpy>=1.20"]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
plots = ["matplotlib>=3.5"]
|
|
32
|
+
test = ["pytest>=7", "matplotlib>=3.5"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/2023popn/Pacejka"
|
|
36
|
+
Issues = "https://github.com/2023popn/Pacejka/issues"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.dynamic]
|
|
39
|
+
version = { attr = "pacejka.__version__" }
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.package-data]
|
|
45
|
+
pacejka = ["py.typed"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
addopts = "-ra"
|
|
50
|
+
markers = [
|
|
51
|
+
"sweep: saves a plot of a parameter sweep (needs matplotlib); deselect with -m 'not sweep'",
|
|
52
|
+
]
|
pacejka-0.1.0/setup.cfg
ADDED
|
File without changes
|