driving_physics 0.0.0.2 → 0.0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,288 +1,120 @@
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
- #
1
+ require 'driving_physics/disk'
6
2
 
7
- require 'driving_physics'
8
-
9
- # treat instances of this class as immutable
10
- # Tire::Condition has mutable attributes
11
- #
12
3
  module DrivingPhysics
13
- class Tire
14
- class Error < RuntimeError; end
15
4
 
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
5
+ # a Tire is a Disk with lighter density and meaningful surface friction
6
+
7
+ class Tire < Disk
8
+ # Note, this is not the density of solid rubber. This density
9
+ # yields a sensible mass for a wheel / tire combo at common radius
10
+ # and width, assuming a uniform density
11
+ # e.g. 25kg at 350mm R x 200mm W
12
+ #
13
+ DENSITY = 0.325 # kg / L
14
+
15
+ # * the traction force opposes the axle torque / drive force
16
+ # thus, driving the car forward
17
+ # * if the drive force exceeds the traction force, slippage occurs
18
+ # * slippage reduces the available traction force further
19
+ # * if the drive force is not reduced, the slippage increases
20
+ # until resistance forces equal the drive force
21
+ def self.traction(normal_force, cof)
22
+ normal_force * cof
23
+ end
24
+
25
+ attr_accessor :mu_s, :mu_k, :omega_friction, :base_friction, :roll_cof
24
26
 
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
27
+ def initialize(env)
28
+ @env = env
29
+ @radius = 350/1000r # m
30
+ @width = 200/1000r # m
31
+ @density = DENSITY
32
+ @temp = @env.air_temp
33
+ @mu_s = 11/10r # static friction
34
+ @mu_k = 7/10r # kinetic friction
35
+ @base_friction = 5/10_000r
36
+ @omega_friction = 5/100_000r # scales with speed
37
+ @roll_cof = DrivingPhysics::ROLL_COF
33
38
 
34
39
  yield self if block_given?
35
- @condition = Condition.new(tread_mm: @tread_mm, cords_mm: @cords_mm)
36
40
  end
37
41
 
38
42
  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,
43
+ [[format("%d mm x %d mm (RxW)", @radius * 1000, @width * 1000),
44
+ format("%.1f kg %.1f C", self.mass, @temp),
45
+ format("cF: %.1f / %.1f", @mu_s, @mu_k),
46
+ ].join(" | "),
44
47
  ].join("\n")
45
48
  end
46
49
 
47
- def tread_left?
48
- @condition.tread_mm > 0.0
50
+ def wear!(amount)
51
+ @radius -= amount
49
52
  end
50
53
 
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
+ def heat!(amount_deg_c)
55
+ @temp += amount_deg_c
54
56
  end
55
57
 
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
58
+ def traction(nf, static: true)
59
+ self.class.traction(nf, static ? @mu_s : @mu_k)
62
60
  end
63
61
 
64
- def temp_factor
65
- @temp_profile.grip_factor(@condition.temp_c)
62
+ # require a normal_force to be be passed in
63
+ def rotating_friction(omega, normal_force:)
64
+ super(omega, normal_force: normal_force)
66
65
  end
67
66
 
68
- def max_g
69
- @g_factor * temp_factor * heat_cycle_factor * tread_factor
67
+ # rolling loss in terms of axle torque
68
+ def rolling_friction(omega, normal_force:)
69
+ return omega if omega.zero?
70
+ mag = omega.abs
71
+ sign = omega / mag
72
+ -1 * sign * (normal_force * @roll_cof) * @radius
70
73
  end
71
74
 
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
75
+ # inertial loss in terms of axle torque when used as a drive wheel
76
+ def inertial_loss(axle_torque, driven_mass:)
77
+ drive_force = self.force(axle_torque)
78
+ force_loss = 0
79
+ # The force loss depends on the acceleration, but the acceleration
80
+ # depends on the force loss. Converge the value via 5 round trips.
81
+ # This is a rough way to compute an integral and should be accurate
82
+ # to 8+ digits.
83
+ 5.times {
84
+ acc = DrivingPhysics.acc(drive_force - force_loss, driven_mass)
85
+ alpha = acc / @radius
86
+ force_loss = self.implied_torque(alpha) / @radius
87
+ }
88
+ force_loss * @radius
129
89
  end
130
90
 
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
91
+ def net_torque(axle_torque, mass:, omega:, normal_force:)
92
+ # friction forces oppose omega
93
+ net = axle_torque +
94
+ self.rolling_friction(omega, normal_force: normal_force) +
95
+ self.rotating_friction(omega, normal_force: normal_force)
186
96
 
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
97
+ # inertial loss has interdependencies; calculate last
98
+ # it opposes net torque, not omega
99
+ sign = net / net.abs
100
+ net - sign * self.inertial_loss(net, driven_mass: mass)
101
+ end
269
102
 
270
- wt = wear_tick(g: g,
271
- slide_speed: slide_speed,
272
- mass: mass,
273
- critical_temp: critical_temp)
103
+ def net_tractable_torque(axle_torque,
104
+ mass:, omega:, normal_force:, static: true)
105
+ net = self.net_torque(axle_torque,
106
+ mass: mass,
107
+ omega: omega,
108
+ normal_force: normal_force)
109
+ tt = self.tractable_torque(normal_force, static: static)
110
+ net > tt ? tt : net
111
+ end
274
112
 
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
113
+ # this doesn't take inertial losses or internal frictional losses
114
+ # into account. input torque required to saturate traction will be
115
+ # higher than what this method returns
116
+ def tractable_torque(nf, static: true)
117
+ traction(nf, static: static) * @radius
286
118
  end
287
119
  end
288
120
  end
@@ -84,12 +84,25 @@ module DrivingPhysics
84
84
  frontal_area: FRONTAL_AREA,
85
85
  drag_cof: DRAG_COF,
86
86
  air_density: AIR_DENSITY)
87
+ return velocity if velocity.zero?
87
88
  -1 * 0.5 * frontal_area * drag_cof * air_density *
88
89
  velocity * velocity.magnitude
89
90
  end
90
91
 
91
- def self.rotational_resistance(velocity, rot_cof: ROT_COF)
92
- -1 * velocity * rot_cof
92
+ # return a force opposing velocity, representing friction / hysteresis
93
+ def self.rotational_resistance(velocity,
94
+ rot_const: ROT_CONST,
95
+ rot_cof: ROT_COF)
96
+ return velocity if velocity.zero?
97
+ -1 * velocity * rot_cof + -1 * velocity.normalize * rot_const
98
+ end
99
+
100
+ # return a torque opposing omega, representing friction / hysteresis
101
+ def self.omega_resistance(omega,
102
+ rot_const: ROT_TQ_CONST,
103
+ rot_cof: ROT_TQ_COF)
104
+ return 0 if omega == 0.0
105
+ omega * ROT_TQ_COF + ROT_TQ_CONST
93
106
  end
94
107
 
95
108
  # dir is drive_force vector or velocity vector; will be normalized
@@ -26,6 +26,7 @@ module DrivingPhysics
26
26
  DRAG_COF = 0.3 # based roughly on 2000s-era Chevrolet Corvette
27
27
  DRAG = 0.4257 # air_resistance at 1 m/s given above numbers
28
28
  ROT_COF = 12.771 # if rotating resistance matches air resistance at 30 m/s
29
+ ROT_CONST = 0.05 # N opposing drive force / torque
29
30
  ROLL_COF = 0.01 # roughly: street tires on concrete
30
31
 
31
32
  #
@@ -35,6 +36,7 @@ module DrivingPhysics
35
36
  MINS_PER_HOUR = 60
36
37
  SECS_PER_HOUR = SECS_PER_MIN * MINS_PER_HOUR
37
38
 
39
+ # HH::MM::SS.mmm
38
40
  def self.elapsed_display(elapsed_ms)
39
41
  elapsed_s, ms = elapsed_ms.divmod 1000
40
42
 
data/test/disk.rb ADDED
@@ -0,0 +1,129 @@
1
+ require 'minitest/autorun'
2
+ require 'driving_physics/disk'
3
+
4
+ D = DrivingPhysics::Disk
5
+
6
+ describe D do
7
+ describe "Disk.volume" do
8
+ it "calculates the volume (m^3) of disk given radius and width" do
9
+ cubic_m = D.volume(1.0, 1.0)
10
+ expect(cubic_m).must_equal Math::PI
11
+
12
+ cubic_m = D.volume(0.35, 0.2)
13
+ expect(cubic_m).must_be_within_epsilon 0.076969
14
+ end
15
+ end
16
+
17
+ describe "Disk.volume_l" do
18
+ it "calculates the volume (L) of a disk given radius and width" do
19
+ liters = D.volume_l(1.0, 1.0)
20
+ expect(liters).must_equal Math::PI * 1000
21
+
22
+ liters = D.volume_l(0.35, 0.2)
23
+ expect(liters).must_be_within_epsilon 76.96902
24
+ end
25
+ end
26
+
27
+ describe "Disk.density" do
28
+ it "calculates the density (kg/L) given mass and volume" do
29
+ expect(D.density(25.0, 25.0)).must_equal 1.0
30
+ expect(D.density(50.0, 25.0)).must_equal 2.0
31
+ end
32
+ end
33
+
34
+ describe "Disk.mass" do
35
+ it "calculates the mass (kg) of a disk given radius, width, and density" do
36
+ skip
37
+ expect(D.mass(0.35, 0.2, D::DENSITY)).must_be_within_epsilon 25.015
38
+ end
39
+ end
40
+
41
+ describe "Disk.rotational_inertia" do
42
+ it "calculates rotational inertia for a disk given radius and mass" do
43
+ expect(D.rotational_inertia(0.35, 25.0)).must_be_within_epsilon 1.53125
44
+ end
45
+ end
46
+
47
+ describe "Disk.alpha" do
48
+ it "calculates angular acceleration from torque and inertia" do
49
+ scalar_torque = 1000
50
+ inertia = D.rotational_inertia(0.35, 25.0)
51
+ expect(D.alpha scalar_torque, inertia).must_be_within_epsilon 653.061
52
+
53
+ vector_torque = Vector[0, 0, 1000]
54
+ vector_alpha = D.alpha vector_torque, inertia
55
+ expect(vector_alpha).must_be_instance_of Vector
56
+ expect(vector_alpha.size).must_equal 3
57
+ expect(vector_alpha[2]).must_be_within_epsilon 653.06
58
+ end
59
+ end
60
+
61
+ describe "Disk.torque_vector" do
62
+ it "calculates a torque in the 3rd dimension given 2D force and radius" do
63
+ force = Vector[1000, 0]
64
+ radius = Vector[0, 5]
65
+ torque = D.torque_vector(force, radius)
66
+ expect(torque).must_be_instance_of Vector
67
+ expect(torque.size).must_equal 3
68
+ expect(torque[2]).must_be_within_epsilon 5000.0
69
+ end
70
+ end
71
+
72
+ describe "Disk.force_vector" do
73
+ it "calculates a (3D) force given 3D torque and 2D radius" do
74
+ # let's invert the Disk.torque_vector case from above:
75
+ torque = Vector[0, 0, 5000]
76
+ radius = Vector[0, 5]
77
+ force = D.force_vector(torque, radius)
78
+ expect(force).must_be_instance_of Vector
79
+ expect(force.size).must_equal 3
80
+ expect(force[0]).must_be_within_epsilon 1000.0
81
+
82
+ # now let's rotate the radius into the x-dimension
83
+ # right hand rule, positive torque means thumb into screen, clockwise
84
+ # negative-x radius means positive-y force
85
+ torque = Vector[0, 0, 500]
86
+ radius = Vector[-5, 0]
87
+ force = D.force_vector(torque, radius)
88
+ expect(force).must_be_instance_of Vector
89
+ expect(force.size).must_equal 3
90
+ expect(force[1]).must_be_within_epsilon 100.0
91
+ end
92
+ end
93
+
94
+ describe "instance methods" do
95
+ before do
96
+ @env = DrivingPhysics::Environment.new
97
+ @disk = D.new(@env)
98
+ end
99
+
100
+ it "initializes" do
101
+ skip
102
+ expect(@disk).must_be_instance_of D
103
+ expect(@disk.density).must_equal D::DENSITY # sanity check
104
+ expect(@disk.mass).must_be_within_epsilon 25.01
105
+
106
+ with_mass = D.new(@env) { |w|
107
+ w.mass = 99.01
108
+ }
109
+ expect(with_mass.mass).must_equal 99.01
110
+ expect(with_mass.density).wont_equal D::DENSITY
111
+ end
112
+
113
+ it "has a string representation" do
114
+ str = @disk.to_s
115
+ expect(str).must_be_instance_of String
116
+ expect(str.length).must_be(:>, 5)
117
+ end
118
+
119
+ it "has volume" do
120
+ expect(@disk.volume).must_be_within_epsilon 0.07697
121
+ expect(@disk.volume_l).must_be_within_epsilon 76.96902
122
+ end
123
+
124
+ it "has inertia" do
125
+ skip
126
+ expect(@disk.rotational_inertia).must_be_within_epsilon 1.5321
127
+ end
128
+ end
129
+ end
data/test/scalar_force.rb CHANGED
@@ -6,25 +6,27 @@ include DrivingPhysics
6
6
  describe ScalarForce do
7
7
  # i.e. multiply this number times speed^2 to approximate drag force
8
8
  it "calculates a reasonable drag constant" do
9
- expect(ScalarForce.air_resistance 1).must_be_within_epsilon DRAG
9
+ expect(ScalarForce.air_resistance 1).must_be_within_epsilon(-1 * DRAG)
10
10
  end
11
11
 
12
12
  # ROT_COF's value is from observing that rotational resistance
13
13
  # matches air resistance at roughly 30 m/s in street cars
14
14
  it "approximates a reasonable rotational resistance constant" do
15
- expect(30 * ScalarForce.air_resistance(1)).must_be_within_epsilon ROT_COF
15
+ expect(30 * ScalarForce.air_resistance(1)).
16
+ must_be_within_epsilon(-1 * ROT_COF)
16
17
  end
17
18
 
18
19
  it "approximates a positive drag force" do
19
- expect(ScalarForce.air_resistance 30).must_be_within_epsilon 383.13
20
+ expect(ScalarForce.air_resistance 30).must_be_within_epsilon(-383.13)
20
21
  end
21
22
 
22
23
  it "approximates a positive rotational resistance force" do
23
- expect(ScalarForce.rotational_resistance 30).must_be_within_epsilon 383.13
24
+ expect(ScalarForce.rotational_resistance 30).
25
+ must_be_within_epsilon(-383.13)
24
26
  end
25
27
 
26
28
  it "approximates a positive rolling resistance force" do
27
29
  nf = 1000 * G
28
- expect(ScalarForce.rolling_resistance nf).must_be_within_epsilon 98.0
30
+ expect(ScalarForce.rolling_resistance nf).must_be_within_epsilon(-98.0)
29
31
  end
30
32
  end