driving_physics 0.0.0.2
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.
- checksums.yaml +7 -0
- data/README.md +83 -0
- data/Rakefile +62 -0
- data/VERSION +1 -0
- data/demo/car.rb +28 -0
- data/demo/scalar_force.rb +26 -0
- data/demo/tire.rb +169 -0
- data/demo/vector_force.rb +26 -0
- data/demo/wheel.rb +84 -0
- data/driving_physics.gemspec +17 -0
- data/lib/driving_physics/car.rb +294 -0
- data/lib/driving_physics/environment.rb +29 -0
- data/lib/driving_physics/imperial.rb +61 -0
- data/lib/driving_physics/scalar_force.rb +68 -0
- data/lib/driving_physics/tire.rb +288 -0
- data/lib/driving_physics/vector_force.rb +136 -0
- data/lib/driving_physics/wheel.rb +191 -0
- data/lib/driving_physics.rb +71 -0
- data/test/car.rb +156 -0
- data/test/driving_physics.rb +29 -0
- data/test/scalar_force.rb +30 -0
- data/test/tire.rb +125 -0
- data/test/vector_force.rb +90 -0
- data/test/wheel.rb +177 -0
- metadata +65 -0
@@ -0,0 +1,288 @@
|
|
1
|
+
# NOTE: This entire file will likely be replaced or removed.
|
2
|
+
# It was created early on, before taking a true physics / formulaic
|
3
|
+
# approach. Just treat it as a toy / PoC for now. It does not
|
4
|
+
# currently behave as desired.
|
5
|
+
#
|
6
|
+
|
7
|
+
require 'driving_physics'
|
8
|
+
|
9
|
+
# treat instances of this class as immutable
|
10
|
+
# Tire::Condition has mutable attributes
|
11
|
+
#
|
12
|
+
module DrivingPhysics
|
13
|
+
class Tire
|
14
|
+
class Error < RuntimeError; end
|
15
|
+
|
16
|
+
attr_accessor :roll_cof,
|
17
|
+
:tread_mm,
|
18
|
+
:cords_mm,
|
19
|
+
:radius_mm,
|
20
|
+
:g_factor,
|
21
|
+
:max_heat_cycles,
|
22
|
+
:temp_profile,
|
23
|
+
:condition
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@roll_cof = ROLL_COF
|
27
|
+
@tread_mm = 10
|
28
|
+
@cords_mm = 1
|
29
|
+
@radius_mm = 350
|
30
|
+
@g_factor = 1.0
|
31
|
+
@max_heat_cycles = 50
|
32
|
+
@temp_profile = TemperatureProfile.new
|
33
|
+
|
34
|
+
yield self if block_given?
|
35
|
+
@condition = Condition.new(tread_mm: @tread_mm, cords_mm: @cords_mm)
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_s
|
39
|
+
[[format("Grip: %.2f / %.1f G", max_g, @g_factor),
|
40
|
+
format("Radius: %d mm", @radius_mm),
|
41
|
+
format("cRR: %.3f", @roll_cof),
|
42
|
+
].join(' | '),
|
43
|
+
@condition,
|
44
|
+
].join("\n")
|
45
|
+
end
|
46
|
+
|
47
|
+
def tread_left?
|
48
|
+
@condition.tread_mm > 0.0
|
49
|
+
end
|
50
|
+
|
51
|
+
# cords give half the traction as tread
|
52
|
+
def tread_factor
|
53
|
+
tread_left? ? 1.0 : 0.5 * @condition.cords_mm / @cords_mm
|
54
|
+
end
|
55
|
+
|
56
|
+
# up to max_heat_cycles, the grip decays down to 80%
|
57
|
+
# beyond max_heat_cycles, the grip decay plateaus at 80%
|
58
|
+
def heat_cycle_factor
|
59
|
+
heat_cycles = [@condition.heat_cycles, @max_heat_cycles].min.to_f
|
60
|
+
heat_pct = heat_cycles / @max_heat_cycles
|
61
|
+
1.0 - 0.2 * heat_pct
|
62
|
+
end
|
63
|
+
|
64
|
+
def temp_factor
|
65
|
+
@temp_profile.grip_factor(@condition.temp_c)
|
66
|
+
end
|
67
|
+
|
68
|
+
def max_g
|
69
|
+
@g_factor * temp_factor * heat_cycle_factor * tread_factor
|
70
|
+
end
|
71
|
+
|
72
|
+
# treat instances of this class as immutable
|
73
|
+
class TemperatureProfile
|
74
|
+
class Error < DrivingPhysics::Tire::Error; end
|
75
|
+
|
76
|
+
TEMPS = [-100, 0, 25, 50, 75, 80, 85, 95, 100, 105, 110, 120, 130, 150]
|
77
|
+
GRIPS = [0.1, 0.5, 0.75, 0.8, 0.9, 0.95, 1.0,
|
78
|
+
0.95, 0.9, 0.75, 0.5, 0.25, 0.1, 0.05]
|
79
|
+
MIN_GRIP = 0.01
|
80
|
+
|
81
|
+
attr_reader :critical_temp
|
82
|
+
|
83
|
+
def initialize(deg_ary = TEMPS, grip_ary = GRIPS)
|
84
|
+
if !deg_ary.is_a?(Array) or !grip_ary.is_a?(Array)
|
85
|
+
raise(ArgumentError, "two arrays are required")
|
86
|
+
end
|
87
|
+
if deg_ary.count != grip_ary.count
|
88
|
+
raise(ArgumentError, "arrays don't match")
|
89
|
+
end
|
90
|
+
@deg_c = deg_ary
|
91
|
+
@grip_pct = grip_ary
|
92
|
+
@critical_temp = nil
|
93
|
+
determine_critical_temp!
|
94
|
+
end
|
95
|
+
|
96
|
+
def grip_factor(temp_c)
|
97
|
+
pct = MIN_GRIP
|
98
|
+
@deg_c.each_with_index { |deg, i|
|
99
|
+
pct = @grip_pct[i] if temp_c >= deg
|
100
|
+
break if temp_c < deg
|
101
|
+
}
|
102
|
+
pct
|
103
|
+
end
|
104
|
+
|
105
|
+
def to_s
|
106
|
+
lines = []
|
107
|
+
@deg_c.each_with_index { |deg, i|
|
108
|
+
lines << [deg, @grip_pct[i]].join("\t")
|
109
|
+
}
|
110
|
+
lines.join("\n")
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
def determine_critical_temp!
|
115
|
+
return @critical_temp if @critical_temp
|
116
|
+
reached_100 = false
|
117
|
+
# go up to 100% grip, then back down to less than 80%
|
118
|
+
@deg_c.each_with_index { |deg, i|
|
119
|
+
next if !reached_100 and @grip_pct[i] < 1.0
|
120
|
+
reached_100 = true if @grip_pct[i] == 1.0
|
121
|
+
if reached_100 and @grip_pct[i] <= 0.8
|
122
|
+
@critical_temp = deg
|
123
|
+
break
|
124
|
+
end
|
125
|
+
}
|
126
|
+
raise(Error, "bad profile, can't find 100% grip") unless reached_100
|
127
|
+
raise(Error, "bad profile, no critical temp") unless @critical_temp
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# treat attributes of this class as *mutable*
|
132
|
+
class Condition
|
133
|
+
class Error < DrivingPhysics::Tire::Error; end
|
134
|
+
class Destroyed < Error; end
|
135
|
+
|
136
|
+
attr_accessor :tread_mm,
|
137
|
+
:cords_mm,
|
138
|
+
:temp_c,
|
139
|
+
:heat_cycles,
|
140
|
+
:debug_temp,
|
141
|
+
:debug_wear
|
142
|
+
|
143
|
+
def initialize(temp_c: AIR_TEMP, tread_mm:, cords_mm:)
|
144
|
+
@tread_mm = tread_mm.to_f
|
145
|
+
@cords_mm = cords_mm.to_f
|
146
|
+
@temp_c = temp_c.to_f
|
147
|
+
@heat_cycles = 0
|
148
|
+
@hottest_temp = @temp_c
|
149
|
+
@debug_temp = false
|
150
|
+
@debug_wear = false
|
151
|
+
end
|
152
|
+
|
153
|
+
def to_s
|
154
|
+
[format("Temp: %.1f C", @temp_c),
|
155
|
+
format("Tread: %.2f (%.1f) mm", @tread_mm, @cords_mm),
|
156
|
+
format("Cycles: %d", @heat_cycles),
|
157
|
+
].join(' | ')
|
158
|
+
end
|
159
|
+
|
160
|
+
def temp_tick(ambient_temp:, g:, slide_speed:,
|
161
|
+
mass:, tire_mass:, critical_temp:)
|
162
|
+
# env:
|
163
|
+
# * mass (kg) (e.g. 1000 kg)
|
164
|
+
# * tire_mass (kg) (e.g. 10 kg)
|
165
|
+
# * critical temp (c) (e.g. 100c)
|
166
|
+
# * g (e.g. 1.0 g)
|
167
|
+
# * slide_speed (m/s) (typically 0.1, up to 1 or 10 or 50)
|
168
|
+
# * ambient_temp (c) (e.g. 30c)
|
169
|
+
|
170
|
+
# g gives a target temp between 25 and 100
|
171
|
+
# at 0g, tire tends to ambient temp
|
172
|
+
# at 1g, tire tends to 100 c
|
173
|
+
# that 100c upper target also gets adjusted due to ambient temps
|
174
|
+
|
175
|
+
target_hot = critical_temp + 5
|
176
|
+
ambient_diff = AIR_TEMP - ambient_temp
|
177
|
+
target_hot -= (ambient_diff / 2)
|
178
|
+
puts "target_hot=#{target_hot}" if @debug_temp
|
179
|
+
|
180
|
+
if slide_speed <= 0.1
|
181
|
+
target_g_temp = ambient_temp + (target_hot - ambient_temp) * g
|
182
|
+
else
|
183
|
+
target_g_temp = target_hot
|
184
|
+
end
|
185
|
+
puts "target_g_temp=#{target_g_temp}" if @debug_temp
|
186
|
+
|
187
|
+
slide_factor = slide_speed * 5
|
188
|
+
target_slide_temp = target_g_temp + slide_factor
|
189
|
+
|
190
|
+
puts "target_slide_temp=#{target_slide_temp}" if @debug_temp
|
191
|
+
|
192
|
+
# temp_tick is presumed to be +1.0 or -1.0 (100th of a degree)
|
193
|
+
# more mass biases towards heat
|
194
|
+
# more tire mass biases for smaller tick
|
195
|
+
|
196
|
+
tick = @temp_c < target_slide_temp ? 1.0 : -1.0
|
197
|
+
tick += slide_speed / 10
|
198
|
+
puts "base tick: #{tick}" if @debug_temp
|
199
|
+
|
200
|
+
mass_factor = (mass - 1000).to_f / 1000
|
201
|
+
if mass_factor < 0
|
202
|
+
# lighter car cools quicker; heats slower
|
203
|
+
tick += mass_factor
|
204
|
+
else
|
205
|
+
# heavier car cools slower, heats quicker
|
206
|
+
tick += mass_factor / 10
|
207
|
+
end
|
208
|
+
puts "mass tick: #{tick}" if @debug_temp
|
209
|
+
|
210
|
+
tire_mass_factor = (tire_mass - 10).to_f / 10
|
211
|
+
if tire_mass_factor < 0
|
212
|
+
# lighter tire has bigger tick
|
213
|
+
tick -= tire_mass_factor
|
214
|
+
else
|
215
|
+
# heavier tire has smaller tick
|
216
|
+
tire_mass_factor = (tire_mass - 10).to_f / 100
|
217
|
+
tick -= tire_mass_factor
|
218
|
+
end
|
219
|
+
puts "tire mass tick: #{tick}" if @debug_temp
|
220
|
+
puts if @debug_temp
|
221
|
+
|
222
|
+
tick
|
223
|
+
end
|
224
|
+
|
225
|
+
def wear_tick(g:, slide_speed:, mass:, critical_temp:)
|
226
|
+
# cold tires wear less
|
227
|
+
tick = [0, @temp_c.to_f / critical_temp].max
|
228
|
+
puts "wear tick: #{tick}" if @debug_wear
|
229
|
+
|
230
|
+
# lower gs reduce wear in the absence of sliding
|
231
|
+
tick *= g if slide_speed <= 0.1
|
232
|
+
puts "g wear tick: #{tick}" if @debug_wear
|
233
|
+
|
234
|
+
# slide wear
|
235
|
+
tick += slide_speed
|
236
|
+
puts "slide wear tick: #{tick}" if @debug_wear
|
237
|
+
puts if @debug_wear
|
238
|
+
tick
|
239
|
+
end
|
240
|
+
|
241
|
+
|
242
|
+
def tick!(ambient_temp:, g:, slide_speed:,
|
243
|
+
mass:, tire_mass:, critical_temp:)
|
244
|
+
|
245
|
+
# heat cycle:
|
246
|
+
# when the tire goes above the critical temp and then
|
247
|
+
# cools significantly below the critical temp
|
248
|
+
# track @hottest_temp
|
249
|
+
|
250
|
+
@temp_c += temp_tick(ambient_temp: ambient_temp,
|
251
|
+
g: g,
|
252
|
+
slide_speed: slide_speed,
|
253
|
+
mass: mass,
|
254
|
+
tire_mass: tire_mass,
|
255
|
+
critical_temp: critical_temp) / 100
|
256
|
+
@hottest_temp = @temp_c if @temp_c > @hottest_temp
|
257
|
+
if @hottest_temp > critical_temp and @temp_c < critical_temp * 0.8
|
258
|
+
@heat_cycles += 1
|
259
|
+
@hottest_temp = @temp_c
|
260
|
+
end
|
261
|
+
|
262
|
+
# a tire should last for 30 minutes at 1g sustained
|
263
|
+
# with minimal sliding
|
264
|
+
# 100 ticks / sec
|
265
|
+
# 6000 ticks / min
|
266
|
+
# 180_000 ticks / 30 min
|
267
|
+
# 10mm = 180_000 ticks
|
268
|
+
# wear_tick is nominally 1 / 18_000 mm
|
269
|
+
|
270
|
+
wt = wear_tick(g: g,
|
271
|
+
slide_speed: slide_speed,
|
272
|
+
mass: mass,
|
273
|
+
critical_temp: critical_temp)
|
274
|
+
|
275
|
+
if @tread_mm > 0
|
276
|
+
@tread_mm -= wt / 18000
|
277
|
+
@tread_mm = 0 if @tread_mm < 0
|
278
|
+
else
|
279
|
+
# cords wear 2x faster
|
280
|
+
@cords_mm -= wt * 2 / 18000
|
281
|
+
if @cords_mm <= 0
|
282
|
+
raise(Destroyed, "no more cords")
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'matrix' # stdlib, provides Vector class
|
2
|
+
require 'driving_physics'
|
3
|
+
|
4
|
+
module DrivingPhysics
|
5
|
+
# compatibility for Vector#zero? in Ruby 2.4.x
|
6
|
+
unless Vector.method_defined?(:zero?)
|
7
|
+
module VectorZeroBackport
|
8
|
+
refine Vector do
|
9
|
+
def zero?
|
10
|
+
all?(&:zero?)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
using VectorZeroBackport
|
15
|
+
end
|
16
|
+
|
17
|
+
# e.g. given 5, yields a uniformly random number from -5 to +5
|
18
|
+
def self.random_centered_zero(magnitude)
|
19
|
+
m = [magnitude.abs, 1].max
|
20
|
+
Random.rand(m * 2 + 1) - m
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.random_unit_vector(dimensions = 2, resolution: 9)
|
24
|
+
begin
|
25
|
+
v = Vector.elements(Array.new(dimensions) {
|
26
|
+
random_centered_zero(resolution)
|
27
|
+
})
|
28
|
+
end while v.zero?
|
29
|
+
v.normalize
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.rot_90(vec, clockwise: true)
|
33
|
+
raise(Vector::ZeroVectorError) if vec.zero?
|
34
|
+
raise(ArgumentError, "vec should be size 2") unless vec.size == 2
|
35
|
+
clockwise ? Vector[vec[1], -1 * vec[0]] : Vector[-1 * vec[1], vec[0]]
|
36
|
+
end
|
37
|
+
|
38
|
+
# +,0 E
|
39
|
+
# 0,+ N
|
40
|
+
# .9,.1 ENE
|
41
|
+
# .1,.9 NNE
|
42
|
+
#
|
43
|
+
def self.compass_dir(unit_vector)
|
44
|
+
horz = case
|
45
|
+
when unit_vector[0] < -0.001 then 'W'
|
46
|
+
when unit_vector[0] > 0.001 then 'E'
|
47
|
+
else ''
|
48
|
+
end
|
49
|
+
|
50
|
+
vert = case
|
51
|
+
when unit_vector[1] < -0.001 then 'S'
|
52
|
+
when unit_vector[1] > 0.001 then 'N'
|
53
|
+
else ''
|
54
|
+
end
|
55
|
+
|
56
|
+
dir = [vert, horz].join
|
57
|
+
if dir.length == 2
|
58
|
+
# detect and include bias
|
59
|
+
if (unit_vector[0].abs - unit_vector[1].abs).abs > 0.2
|
60
|
+
bias = unit_vector[0].abs > unit_vector[1].abs ? horz : vert
|
61
|
+
dir = [bias, dir].join
|
62
|
+
end
|
63
|
+
end
|
64
|
+
dir
|
65
|
+
end
|
66
|
+
|
67
|
+
module VectorForce
|
68
|
+
#
|
69
|
+
# Resistance Forces
|
70
|
+
#
|
71
|
+
# 1. air resistance aka drag aka turbulent drag
|
72
|
+
# depends on v^2
|
73
|
+
# 2. "rotatational" resistance, e.g. bearings / axles / lubricating fluids
|
74
|
+
# aka viscous drag; linear with v
|
75
|
+
# 3. rolling resistance, e.g. tire and surface deformation
|
76
|
+
# constant with v, depends on normal force and tire/surface properties
|
77
|
+
# 4. braking resistance, supplied by operator, constant with v
|
78
|
+
# depends purely on operator choice and physical limits
|
79
|
+
# as such, it is not modeled here
|
80
|
+
#
|
81
|
+
|
82
|
+
# velocity is a vector; return value is a force vector
|
83
|
+
def self.air_resistance(velocity,
|
84
|
+
frontal_area: FRONTAL_AREA,
|
85
|
+
drag_cof: DRAG_COF,
|
86
|
+
air_density: AIR_DENSITY)
|
87
|
+
-1 * 0.5 * frontal_area * drag_cof * air_density *
|
88
|
+
velocity * velocity.magnitude
|
89
|
+
end
|
90
|
+
|
91
|
+
def self.rotational_resistance(velocity, rot_cof: ROT_COF)
|
92
|
+
-1 * velocity * rot_cof
|
93
|
+
end
|
94
|
+
|
95
|
+
# dir is drive_force vector or velocity vector; will be normalized
|
96
|
+
# normal_force is a magnitude, not a vector
|
97
|
+
#
|
98
|
+
def self.rolling_resistance(nf_mag, dir:, roll_cof: ROLL_COF)
|
99
|
+
return dir if dir.zero? # don't try to normalize a zero vector
|
100
|
+
nf_mag = nf_mag.magnitude if nf_mag.is_a? Vector
|
101
|
+
-1 * dir.normalize * roll_cof * nf_mag
|
102
|
+
end
|
103
|
+
|
104
|
+
#
|
105
|
+
# convenience methods
|
106
|
+
#
|
107
|
+
|
108
|
+
def self.velocity_resistance(velocity,
|
109
|
+
frontal_area: FRONTAL_AREA,
|
110
|
+
drag_cof: DRAG_COF,
|
111
|
+
air_density: AIR_DENSITY,
|
112
|
+
rot_cof: ROT_COF)
|
113
|
+
air_resistance(velocity,
|
114
|
+
frontal_area: frontal_area,
|
115
|
+
drag_cof: drag_cof,
|
116
|
+
air_density: air_density) +
|
117
|
+
rotational_resistance(velocity, rot_cof: rot_cof)
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.all_resistance(velocity,
|
121
|
+
frontal_area: FRONTAL_AREA,
|
122
|
+
drag_cof: DRAG_COF,
|
123
|
+
air_density: AIR_DENSITY,
|
124
|
+
rot_cof: ROT_COF,
|
125
|
+
dir:,
|
126
|
+
nf_mag:,
|
127
|
+
roll_cof: ROLL_COF)
|
128
|
+
velocity_resistance(velocity,
|
129
|
+
frontal_area: frontal_area,
|
130
|
+
drag_cof: drag_cof,
|
131
|
+
air_density: air_density,
|
132
|
+
rot_cof: rot_cof) +
|
133
|
+
rolling_resistance(nf_mag, dir: dir, roll_cof: roll_cof)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'driving_physics/environment'
|
2
|
+
require 'driving_physics/vector_force'
|
3
|
+
|
4
|
+
module DrivingPhysics
|
5
|
+
# Rotational complements to acc/vel/pos
|
6
|
+
# alpha - angular acceleration
|
7
|
+
# omega - angular velocity (radians / s)
|
8
|
+
# theta - radians
|
9
|
+
|
10
|
+
class Wheel
|
11
|
+
# Note, this is not the density of solid rubber. This density
|
12
|
+
# yields a sensible mass for a wheel / tire combo at common radius
|
13
|
+
# and width, assuming a uniform density
|
14
|
+
# e.g. 25kg at 350mm R x 200mm W
|
15
|
+
#
|
16
|
+
DENSITY = 0.325 # kg / L
|
17
|
+
|
18
|
+
# * the traction force opposes the axle torque / drive force
|
19
|
+
# thus, driving the car forward
|
20
|
+
# * if the drive force exceeds the traction force, slippage occurs
|
21
|
+
# * slippage reduces the available traction force further
|
22
|
+
# * if the drive force is not reduced, the slippage increases
|
23
|
+
# until resistance forces equal the drive force
|
24
|
+
def self.traction(normal_force, cof)
|
25
|
+
normal_force * cof
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.force(axle_torque, radius_m)
|
29
|
+
axle_torque / radius_m.to_f
|
30
|
+
end
|
31
|
+
|
32
|
+
# in m^3
|
33
|
+
def self.volume(radius_m, width_m)
|
34
|
+
Math::PI * radius_m ** 2 * width_m.to_f
|
35
|
+
end
|
36
|
+
|
37
|
+
# in L
|
38
|
+
def self.volume_l(radius_m, width_m)
|
39
|
+
volume(radius_m, width_m) * 1000
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.density(mass, volume_l)
|
43
|
+
mass.to_f / volume_l
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.mass(radius_m, width_m, density)
|
47
|
+
density * volume_l(radius_m, width_m)
|
48
|
+
end
|
49
|
+
|
50
|
+
# I = 1/2 (m)(r^2) for a disk
|
51
|
+
def self.rotational_inertia(radius_m, mass)
|
52
|
+
mass * radius_m**2 / 2.0
|
53
|
+
end
|
54
|
+
class << self
|
55
|
+
alias_method(:moment_of_inertia, :rotational_inertia)
|
56
|
+
end
|
57
|
+
|
58
|
+
# angular acceleration
|
59
|
+
def self.alpha(torque, inertia)
|
60
|
+
torque / inertia
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.tangential(rotational, radius_m)
|
64
|
+
rotational * radius_m
|
65
|
+
end
|
66
|
+
class << self
|
67
|
+
alias_method(:tangential_a, :tangential)
|
68
|
+
alias_method(:tangential_v, :tangential)
|
69
|
+
alias_method(:tangential_p, :tangential)
|
70
|
+
end
|
71
|
+
|
72
|
+
# vectors only
|
73
|
+
def self.torque_vector(force, radius)
|
74
|
+
if !force.is_a?(Vector) or force.size != 2
|
75
|
+
raise(ArgumentError, "force must be a 2D vector")
|
76
|
+
end
|
77
|
+
if !radius.is_a?(Vector) or radius.size != 2
|
78
|
+
raise(ArgumentError, "radius must be a 2D vector")
|
79
|
+
end
|
80
|
+
force = Vector[force[0], force[1], 0]
|
81
|
+
radius = Vector[radius[0], radius[1], 0]
|
82
|
+
force.cross(radius)
|
83
|
+
end
|
84
|
+
|
85
|
+
# vectors only
|
86
|
+
def self.force_vector(torque, radius)
|
87
|
+
if !torque.is_a?(Vector) or torque.size != 3
|
88
|
+
raise(ArgumentError, "torque must be a 3D vector")
|
89
|
+
end
|
90
|
+
if !radius.is_a?(Vector) or radius.size != 2
|
91
|
+
raise(ArgumentError, "radius must be a 2D vector")
|
92
|
+
end
|
93
|
+
radius = Vector[radius[0], radius[1], 0]
|
94
|
+
radius.cross(torque) / radius.dot(radius)
|
95
|
+
end
|
96
|
+
|
97
|
+
attr_reader :env, :radius, :radius_m, :width, :width_m, :density, :temp,
|
98
|
+
:mu_s, :mu_k, :omega_friction
|
99
|
+
|
100
|
+
def initialize(env,
|
101
|
+
radius: 350, width: 200, density: DENSITY,
|
102
|
+
temp: nil, mass: nil,
|
103
|
+
mu_s: 1.1, mu_k: 0.7,
|
104
|
+
omega_friction: 0.002)
|
105
|
+
@env = env
|
106
|
+
@radius = radius.to_f # mm
|
107
|
+
@radius_m = @radius / 1000
|
108
|
+
@width = width.to_f # mm
|
109
|
+
@width_m = @width / 1000
|
110
|
+
@mu_s = mu_s.to_f # static friction
|
111
|
+
@mu_k = mu_k.to_f # kinetic friction
|
112
|
+
@omega_friction = omega_friction # scales with speed
|
113
|
+
@density = mass.nil? ? density : self.class.density(mass, volume_l)
|
114
|
+
@temp = temp.to_f || @env.air_temp
|
115
|
+
end
|
116
|
+
|
117
|
+
def to_s
|
118
|
+
[[format("%d mm (R) x %d mm (W)", @radius, @width),
|
119
|
+
format("Mass: %.1f kg %.3f kg/L", self.mass, @density),
|
120
|
+
format("cF: %.1f / %.1f", @mu_s, @mu_k),
|
121
|
+
].join(" | "),
|
122
|
+
[format("Temp: %.1f C", @temp),
|
123
|
+
].join(" | "),
|
124
|
+
].join("\n")
|
125
|
+
end
|
126
|
+
|
127
|
+
def wear!(amount_mm)
|
128
|
+
@radius -= amount_mm
|
129
|
+
@radius_m = @radius / 1000
|
130
|
+
end
|
131
|
+
|
132
|
+
def heat!(amount_deg_c)
|
133
|
+
@temp += amount_deg_c
|
134
|
+
end
|
135
|
+
|
136
|
+
def mass
|
137
|
+
self.class.mass(@radius_m, @width_m, @density)
|
138
|
+
end
|
139
|
+
|
140
|
+
# in m^3
|
141
|
+
def volume
|
142
|
+
self.class.volume(@radius_m, @width_m)
|
143
|
+
end
|
144
|
+
|
145
|
+
# in L
|
146
|
+
def volume_l
|
147
|
+
self.class.volume_l(@radius_m, @width_m)
|
148
|
+
end
|
149
|
+
|
150
|
+
def rotational_inertia
|
151
|
+
self.class.rotational_inertia(@radius_m, self.mass)
|
152
|
+
end
|
153
|
+
alias_method(:moment_of_inertia, :rotational_inertia)
|
154
|
+
|
155
|
+
def traction(nf, static: true)
|
156
|
+
self.class.traction(nf, static ? @mu_s : @mu_k)
|
157
|
+
end
|
158
|
+
|
159
|
+
def force(axle_torque)
|
160
|
+
self.class.force(axle_torque, @radius_m)
|
161
|
+
end
|
162
|
+
|
163
|
+
# how much torque to accelerate rotational inertia at alpha
|
164
|
+
def inertial_torque(alpha)
|
165
|
+
alpha * self.rotational_inertia
|
166
|
+
end
|
167
|
+
|
168
|
+
# this doesn't take inertial losses or internal frictional losses
|
169
|
+
# into account. input torque required to saturate traction will be
|
170
|
+
# higher than what this method returns
|
171
|
+
def tractable_torque(nf, static: true)
|
172
|
+
traction(nf, static: static) * @radius_m
|
173
|
+
end
|
174
|
+
|
175
|
+
# inertial loss in terms of axle torque when used as a drive wheel
|
176
|
+
def inertial_loss(axle_torque, total_driven_mass)
|
177
|
+
drive_force = self.force(axle_torque)
|
178
|
+
force_loss = 0
|
179
|
+
# The force loss depends on the acceleration, but the acceleration
|
180
|
+
# depends on the force loss. Converge the value via 5 round trips.
|
181
|
+
# This is a rough way to compute an integral and should be accurate
|
182
|
+
# to 8+ digits.
|
183
|
+
5.times {
|
184
|
+
acc = DrivingPhysics.acc(drive_force - force_loss, total_driven_mass)
|
185
|
+
alpha = acc / @radius_m
|
186
|
+
force_loss = self.inertial_torque(alpha) / @radius_m
|
187
|
+
}
|
188
|
+
force_loss * @radius_m
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module DrivingPhysics
|
2
|
+
#
|
3
|
+
# Units: metric
|
4
|
+
#
|
5
|
+
# distance: meter
|
6
|
+
# velocity: meter / sec
|
7
|
+
# acceleration: meter / sec^2
|
8
|
+
# volume: Liter
|
9
|
+
# temperature: Celsius
|
10
|
+
#
|
11
|
+
|
12
|
+
#
|
13
|
+
# environmental defaults
|
14
|
+
#
|
15
|
+
HZ = 1000
|
16
|
+
TICK = Rational(1) / HZ
|
17
|
+
G = 9.8 # m/s^2
|
18
|
+
AIR_TEMP = 25 # deg c
|
19
|
+
AIR_DENSITY = 1.29 # kg / m^3
|
20
|
+
PETROL_DENSITY = 0.71 # kg / L TODO: move to car.rb
|
21
|
+
|
22
|
+
#
|
23
|
+
# defaults for resistance forces
|
24
|
+
#
|
25
|
+
FRONTAL_AREA = 2.2 # m^2, based roughly on 2000s-era Chevrolet Corvette
|
26
|
+
DRAG_COF = 0.3 # based roughly on 2000s-era Chevrolet Corvette
|
27
|
+
DRAG = 0.4257 # air_resistance at 1 m/s given above numbers
|
28
|
+
ROT_COF = 12.771 # if rotating resistance matches air resistance at 30 m/s
|
29
|
+
ROLL_COF = 0.01 # roughly: street tires on concrete
|
30
|
+
|
31
|
+
#
|
32
|
+
# constants
|
33
|
+
#
|
34
|
+
SECS_PER_MIN = 60
|
35
|
+
MINS_PER_HOUR = 60
|
36
|
+
SECS_PER_HOUR = SECS_PER_MIN * MINS_PER_HOUR
|
37
|
+
|
38
|
+
def self.elapsed_display(elapsed_ms)
|
39
|
+
elapsed_s, ms = elapsed_ms.divmod 1000
|
40
|
+
|
41
|
+
h = elapsed_s / SECS_PER_HOUR
|
42
|
+
elapsed_s -= h * SECS_PER_HOUR
|
43
|
+
m, s = elapsed_s.divmod SECS_PER_MIN
|
44
|
+
|
45
|
+
[[h, m, s].map { |i| i.to_s.rjust(2, '0') }.join(':'),
|
46
|
+
ms.to_s.rjust(3, '0')].join('.')
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.kph(meters_per_sec)
|
50
|
+
meters_per_sec.to_f * SECS_PER_HOUR / 1000
|
51
|
+
end
|
52
|
+
|
53
|
+
# acceleration; F=ma
|
54
|
+
# force can be a scalar or a Vector
|
55
|
+
def self.acc(force, mass)
|
56
|
+
force / mass.to_f
|
57
|
+
end
|
58
|
+
|
59
|
+
# init and rate can be scalar or Vector but must match
|
60
|
+
# this provides the general form for determining velocity and position
|
61
|
+
def self.accum(init, rate, dt: TICK)
|
62
|
+
init + rate * dt
|
63
|
+
end
|
64
|
+
|
65
|
+
class << self
|
66
|
+
alias_method(:vel, :accum)
|
67
|
+
alias_method(:pos, :accum)
|
68
|
+
alias_method(:omega, :accum)
|
69
|
+
alias_method(:theta, :accum)
|
70
|
+
end
|
71
|
+
end
|