driving_physics 0.0.0.2 → 0.0.1.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.
@@ -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