driving_physics 0.0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|