driving_physics 0.0.0.2

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