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.
@@ -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