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,294 +1,123 @@
1
- require 'driving_physics/environment'
2
- require 'driving_physics/vector_force'
3
1
  require 'driving_physics/tire'
2
+ require 'driving_physics/powertrain'
4
3
 
5
4
  module DrivingPhysics
6
- # treat instances of this class as immutable
7
5
  class Car
8
- attr_accessor :mass, :min_turn_radius,
9
- :max_drive_force, :max_brake_clamp, :max_brake_force,
10
- :fuel_capacity, :brake_pad_depth, :driver_mass,
11
- :frontal_area, :cd, :fuel_consumption,
12
- :tires, :controls, :condition
13
-
14
- def initialize(environment)
15
- @environment = environment
16
- @mass = 1000 # kg, without fuel or driver
17
- @min_turn_radius = 10 # meters
18
- @max_drive_force = 7000 # N - 1000kg car at 0.7g acceleration
19
- @max_brake_clamp = 100 # N
20
- @max_brake_force = 40_000 # N - 2000kg car at 2g braking
21
- @fuel_capacity = 40 # L
22
- @brake_pad_depth = 10 # mm
23
- @driver_mass = 75 # kg
24
- @fuel_consumption = 0.02 # L/s at full throttle
25
-
26
- @frontal_area = DrivingPhysics::FRONTAL_AREA # m^2
6
+ attr_reader :tire, :powertrain, :env
7
+ attr_accessor :num_tires, :body_mass, :frontal_area, :cd
8
+
9
+ def initialize(tire:, powertrain:)
10
+ @num_tires = 4
11
+ @tire = tire
12
+ @env = @tire.env
13
+ @powertrain = powertrain
14
+ @body_mass = 1000.0
15
+ @frontal_area = DrivingPhysics::FRONTAL_AREA
27
16
  @cd = DrivingPhysics::DRAG_COF
28
17
 
29
- @tires = Tire.new
30
- @controls = Controls.new
31
- @condition = Condition.new(brake_temp: @environment.air_temp,
32
- brake_pad_depth: @brake_pad_depth)
33
-
34
- # consider downforce
35
- # probably area * angle
36
- # goes up with square of velocity
37
-
38
18
  yield self if block_given?
39
19
  end
40
20
 
41
- def to_s
42
- [[format("Mass: %.1f kg", total_mass),
43
- format("Power: %.1f kN", @max_drive_force.to_f / 1000),
44
- format("Brakes: %.1f kN", @max_brake_force.to_f / 1000),
45
- format("Fr.A: %.2f m^2", @frontal_area),
46
- format("cD: %.2f", @cd),
47
- ].join(' | '),
48
- [format("Op: %d N", drive_force - brake_force),
49
- format("Drive: %d N", drive_force),
50
- format("Brake: %d N", brake_force),
51
- ].join(' | '),
52
- [format("Net: %.1f N", sum_forces.magnitude),
53
- format("Air: %.1f N", air_resistance.magnitude),
54
- format("Rot: %.1f N", rotational_resistance.magnitude),
55
- format("Roll: %.1f N", rolling_resistance.magnitude),
56
- ].join(' | '),
57
- @controls, @condition, @tires,
58
- ].join("\n")
21
+ def throttle
22
+ @powertrain.motor.throttle
59
23
  end
60
24
 
61
- def tick!
62
- @condition.tick!(force: sum_forces,
63
- mass: total_mass,
64
- tire: @tires,
65
- env: @environment)
66
-
67
- @condition.consume_fuel(@fuel_consumption *
68
- @controls.drive_pedal *
69
- @environment.tick)
25
+ def throttle=(val)
26
+ @powertrain.motor.throttle = val
70
27
  end
71
28
 
72
- def drive_force
73
- @condition.fuel > 0.0 ? (@max_drive_force * @controls.drive_pedal) : 0.0
29
+ def gear
30
+ @powertrain.gearbox.gear
74
31
  end
75
32
 
76
- def drive_force_vector
77
- @condition.dir * drive_force
33
+ def gear=(val)
34
+ @powertrain.gearbox.gear = val
78
35
  end
79
36
 
80
- def brake_force
81
- @max_brake_force * @controls.brake_pedal
37
+ def top_gear
38
+ @powertrain.gearbox.top_gear
82
39
  end
83
40
 
84
- def brake_force_vector
85
- -1 * @condition.movement_dir * brake_force
41
+ # force opposing speed
42
+ def air_force(speed)
43
+ -0.5 * @frontal_area * @cd * @env.air_density * speed ** 2
86
44
  end
87
45
 
88
- def fuel_mass
89
- @condition.fuel * @environment.petrol_density
46
+ # force of opposite sign to omega
47
+ def tire_rolling_force(omega)
48
+ @num_tires *
49
+ @tire.rolling_friction(omega, normal_force: self.normal_force) /
50
+ @tire.radius
90
51
  end
91
52
 
92
- def total_mass
93
- @mass + fuel_mass + @driver_mass
53
+ # force of opposite sign to omega
54
+ def tire_rotational_force(omega)
55
+ @num_tires *
56
+ @tire.rotating_friction(omega, normal_force: self.normal_force) /
57
+ @tire.radius
94
58
  end
95
59
 
96
- def weight
97
- total_mass * @environment.g
60
+ # force of opposite sign to force
61
+ def tire_inertial_force(force)
62
+ mag = force.abs
63
+ sign = force / mag
64
+ force_loss = 0
65
+ 5.times {
66
+ # use magnitude, so we are subtracting only positive numbers
67
+ acc = DrivingPhysics.acc(mag - force_loss, self.total_mass)
68
+ alpha = acc / @tire.radius
69
+ # this will be a positive number
70
+ force_loss = @num_tires * @tire.implied_torque(alpha) /
71
+ @tire.radius
72
+ }
73
+ # oppose initial force
74
+ -1 * sign * force_loss
98
75
  end
99
76
 
100
- def add_fuel(liters)
101
- sum = @condition.fuel + liters
102
- overflow = sum > @fuel_capacity ? sum - @fuel_capacity : 0
103
- @condition.add_fuel(liters - overflow)
104
- overflow
77
+ def to_s
78
+ [[format("Mass: %.1f kg", self.total_mass),
79
+ format("Fr.A: %.2f m^2", @frontal_area),
80
+ format("cD: %.2f", @cd),
81
+ ].join(' | '),
82
+ format("POWERTRAIN:\n%s", @powertrain),
83
+ format("Tires: %s", @tire),
84
+ format("Corner mass: %.1f kg | Normal force: %.1f N",
85
+ self.corner_mass, self.normal_force),
86
+ ].join("\n")
105
87
  end
106
88
 
107
- def air_resistance
108
- # use default air density for now
109
- VectorForce.air_resistance(@condition.vel,
110
- frontal_area: @frontal_area,
111
- drag_cof: @cd)
89
+ def drive_force(rpm)
90
+ @tire.force(@powertrain.axle_torque(rpm))
112
91
  end
113
92
 
114
- def rotational_resistance
115
- # uses default ROT_COF
116
- VectorForce.rotational_resistance(@condition.vel)
93
+ def tire_speed(rpm)
94
+ @tire.tangential(@powertrain.axle_omega(rpm))
117
95
  end
118
96
 
119
- def rolling_resistance
120
- # TODO: downforce
121
- VectorForce.rolling_resistance(weight,
122
- dir: @condition.movement_dir,
123
- roll_cof: @tires.roll_cof)
97
+ def motor_rpm(tire_speed)
98
+ @powertrain.gearbox.crank_rpm(tire_speed / @tire_radius)
124
99
  end
125
100
 
126
- def applied_force
127
- drive_force_vector + brake_force_vector
101
+ def total_mass
102
+ @body_mass + @powertrain.mass + @tire.mass * @num_tires
128
103
  end
129
104
 
130
- def natural_force
131
- air_resistance + rotational_resistance + rolling_resistance
105
+ def corner_mass
106
+ Rational(self.total_mass) / @num_tires
132
107
  end
133
108
 
134
- def sum_forces
135
- applied_force + natural_force
109
+ # per tire
110
+ def normal_force
111
+ self.corner_mass * @env.g
136
112
  end
137
113
 
138
- class Controls
139
- attr_reader :drive_pedal, :brake_pedal, :steering_wheel
140
-
141
- def initialize
142
- @drive_pedal = 0.0 # up to 1.0
143
- @brake_pedal = 0.0 # up to 1.0
144
- @steering_wheel = 0.0 # -1.0 to 1.0
145
- end
146
-
147
- def drive_pedal=(flt)
148
- @drive_pedal = flt.clamp(0.0, 1.0)
149
- end
150
-
151
- def brake_pedal=(flt)
152
- @brake_pedal = flt.clamp(0.0, 1.0)
153
- end
154
-
155
- def steering_wheel=(flt)
156
- @steering_wheel = steering_wheel.clamp(-1.0, 1.0)
157
- end
158
-
159
- def to_s
160
- [format("Throttle: %d%%", @drive_pedal * 100),
161
- format("Brake: %d%%", @brake_pedal * 100),
162
- format("Steering: %d%%", @steering_wheel * 100),
163
- ].join(" | ")
164
- end
114
+ # per tire
115
+ def tire_traction
116
+ @tire.traction(self.normal_force)
165
117
  end
166
118
 
167
- class Condition
168
- unless Vector.method_defined?(:zero?)
169
- using VectorZeroBackport
170
- end
171
-
172
- attr_reader :dir, :pos, :vel, :acc, :fuel, :lat_g, :lon_g,
173
- :wheelspeed, :brake_temp, :brake_pad_depth
174
-
175
- def initialize(dir: DrivingPhysics.random_unit_vector,
176
- brake_temp: AIR_TEMP,
177
- brake_pad_depth: )
178
- @dir = dir # maybe rename to @heading ?
179
- @pos = Vector[0, 0]
180
- @vel = Vector[0, 0]
181
- @acc = Vector[0, 0]
182
- @fuel = 0.0 # L
183
- @lat_g = 0.0 # g
184
- @lon_g = 0.0 # g
185
- @wheelspeed = 0.0 # m/s (sliding when it differs from @speed)
186
- @brake_temp = brake_temp
187
- @brake_pad_depth = brake_pad_depth # mm
188
- end
189
-
190
- def to_s
191
- [[compass,
192
- format('P(%d, %d)', @pos[0], @pos[1]),
193
- format('V(%.1f, %.1f)', @vel[0], @vel[1]),
194
- format('A(%.2f, %.2f)', @acc[0], @acc[1]),
195
- ].join(' | '),
196
- [format('%.1f m/s', speed),
197
- format('LatG: %.2f', lat_g),
198
- format('LonG: %.2f', lon_g),
199
- format('Whl: %.1f m/s', @wheelspeed),
200
- format('Slide: %.1f m/s', slide_speed),
201
- ].join(' | '),
202
- [format('Brakes: %.1f C %.1f mm', @brake_temp, @brake_pad_depth),
203
- format('Fuel: %.2f L', @fuel),
204
- ].join(' | ')
205
- ].join("\n")
206
- end
207
-
208
- # left is negative, right is positive
209
- def lat_dir
210
- DrivingPhysics.rot_90(@dir, clockwise: true)
211
- end
212
-
213
- # note, we might be moving backwards, so not always @dir
214
- # and we can't normalize a zero vector if we're not moving
215
- def movement_dir
216
- (speed == 0.0) ? @dir : @vel.normalize
217
- end
218
-
219
- def tick!(force:, mass:, tire:, env:)
220
- # take the longitudinal component of the force, relative to vel dir
221
- vel_dir = @vel.zero? ? @dir : @vel.normalize
222
- lon_force = force.dot(vel_dir)
223
- @wheelspeed = nil
224
-
225
- if lon_force < 0.0
226
- is_stopping = true
227
- if @vel.zero?
228
- @acc = Vector[0,0]
229
- @wheelspeed = 0.0
230
- @lon_g = 0.0
231
-
232
- # TODO: update any other physical vars
233
- return
234
- end
235
- else
236
- is_stopping = false
237
- end
238
-
239
- # now detect sliding
240
- nominal_acc = DrivingPhysics.acc(force, mass)
241
- init_v = @vel
242
-
243
- if nominal_acc.magnitude > tire.max_g * env.g
244
- nominal_v = DrivingPhysics.vel(@vel, nominal_acc, dt: env.tick)
245
-
246
- # check for reversal of velocity; could be wheelspin while
247
- # moving backwards, so can't just look at is_stopping
248
- if nominal_v.dot(@vel) < 0 and is_stopping
249
- @wheelspeed = 0.0
250
- else
251
- @wheelspeed = nominal_v.magnitude
252
- end
253
- @acc = nominal_acc.normalize * tire.max_g * env.g
254
- else
255
- @acc = nominal_acc
256
- end
257
-
258
- @vel = DrivingPhysics.vel(@vel, @acc, dt: env.tick)
259
- @wheelspeed ||= @vel.magnitude
260
-
261
- # finally, detect velocity reversal when stopping
262
- if is_stopping and @vel.dot(init_v) < 0
263
- puts "crossed zero; stopping!"
264
- @vel = Vector[0, 0]
265
- @wheelspeed = 0.0
266
- @lon_g = 0.0
267
- end
268
-
269
- @lon_g = @acc.dot(@dir) / env.g
270
- @pos = DrivingPhysics.pos(@pos, @vel, dt: env.tick)
271
- end
272
-
273
- def add_fuel(liters)
274
- @fuel += liters
275
- end
276
-
277
- def consume_fuel(liters)
278
- @fuel -= liters
279
- end
280
-
281
- def speed
282
- @vel.magnitude
283
- end
284
-
285
- def slide_speed
286
- (speed - @wheelspeed).abs
287
- end
288
-
289
- def compass
290
- DrivingPhysics.compass_dir(@dir)
291
- end
119
+ def total_normal_force
120
+ self.total_mass * env.g
292
121
  end
293
122
  end
294
123
  end
@@ -0,0 +1,51 @@
1
+ module DrivingPhysics
2
+ module CLI
3
+ # returns user input as a string
4
+ def self.prompt(msg = '')
5
+ print msg + ' ' unless msg.empty?
6
+ print '> '
7
+ $stdin.gets.chomp
8
+ end
9
+
10
+ # press Enter to continue, ignore input, return elapsed time
11
+ def self.pause(msg = '')
12
+ t = Timer.now
13
+ puts msg unless msg.empty?
14
+ puts ' [ Press Enter ]'
15
+ $stdin.gets
16
+ Timer.since(t)
17
+ end
18
+ end
19
+
20
+ module Timer
21
+ if defined? Process::CLOCK_MONOTONIC
22
+ def self.now
23
+ Process.clock_gettime Process::CLOCK_MONOTONIC
24
+ end
25
+ else
26
+ def self.now
27
+ Time.now
28
+ end
29
+ end
30
+
31
+ def self.since(t)
32
+ self.now - t
33
+ end
34
+
35
+ def self.elapsed(&work)
36
+ t = self.now
37
+ return yield, self.since(t)
38
+ end
39
+
40
+ # HH:MM:SS.mmm
41
+ def self.display(seconds: 0, ms: 0)
42
+ ms += (seconds * 1000).round if seconds > 0
43
+ DrivingPhysics.elapsed_display(ms)
44
+ end
45
+
46
+ def self.summary(start, num_ticks, paused = 0)
47
+ elapsed = self.since(start) - paused
48
+ format("%.3f s (%d ticks/s)", elapsed, num_ticks.to_f / elapsed)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,185 @@
1
+ require 'driving_physics/environment'
2
+ require 'driving_physics/vector_force'
3
+
4
+ module DrivingPhysics
5
+ # radius is always in meters
6
+ # force in N
7
+ # torque in Nm
8
+
9
+ # Rotational complements to acc/vel/pos
10
+ # alpha - angular acceleration (radians / s / s)
11
+ # omega - angular velocity (radians / s)
12
+ # theta - radians
13
+
14
+ # convert radians to revolutions; works for alpha/omega/theta
15
+ def self.revs(rads)
16
+ rads / (2 * Math::PI)
17
+ end
18
+
19
+ # convert revs to rads; works for alpha/omega/theta
20
+ def self.rads(revs)
21
+ revs * 2 * Math::PI
22
+ end
23
+
24
+ # convert rpm to omega (rads / s)
25
+ def self.omega(rpm)
26
+ self.rads(rpm / 60.0)
27
+ end
28
+
29
+ # convert omega to RPM (revs per minute)
30
+ def self.rpm(omega)
31
+ self.revs(omega) * 60
32
+ end
33
+
34
+ class Disk
35
+ DENSITY = 1.0 # kg / L
36
+
37
+ # torque = force * distance
38
+ def self.force(axle_torque, radius)
39
+ axle_torque / radius.to_f
40
+ end
41
+
42
+ # in m^3
43
+ def self.volume(radius, width)
44
+ Math::PI * radius ** 2 * width
45
+ end
46
+
47
+ # in L
48
+ def self.volume_l(radius, width)
49
+ volume(radius, width) * 1000
50
+ end
51
+
52
+ def self.density(mass, volume_l)
53
+ mass.to_f / volume_l
54
+ end
55
+
56
+ def self.mass(radius, width, density)
57
+ volume_l(radius, width) * density
58
+ end
59
+
60
+ # I = 1/2 (m)(r^2) for a disk
61
+ def self.rotational_inertia(radius, mass)
62
+ mass * radius**2 / 2.0
63
+ end
64
+ class << self
65
+ alias_method(:moment_of_inertia, :rotational_inertia)
66
+ end
67
+
68
+ # angular acceleration
69
+ def self.alpha(torque, inertia)
70
+ torque / inertia
71
+ end
72
+
73
+ # convert alpha/omega/theta to acc/vel/pos
74
+ def self.tangential(rotational, radius)
75
+ rotational * radius
76
+ end
77
+
78
+ # convert acc/vel/pos to alpha/omega/theta
79
+ def self.rotational(tangential, radius)
80
+ tangential.to_f / radius
81
+ end
82
+
83
+ # vectors only
84
+ def self.torque_vector(force, radius)
85
+ if !force.is_a?(Vector) or force.size != 2
86
+ raise(ArgumentError, "force must be a 2D vector")
87
+ end
88
+ if !radius.is_a?(Vector) or radius.size != 2
89
+ raise(ArgumentError, "radius must be a 2D vector")
90
+ end
91
+ force = Vector[force[0], force[1], 0]
92
+ radius = Vector[radius[0], radius[1], 0]
93
+ force.cross(radius)
94
+ end
95
+
96
+ # vectors only
97
+ def self.force_vector(torque, radius)
98
+ if !torque.is_a?(Vector) or torque.size != 3
99
+ raise(ArgumentError, "torque must be a 3D vector")
100
+ end
101
+ if !radius.is_a?(Vector) or radius.size != 2
102
+ raise(ArgumentError, "radius must be a 2D vector")
103
+ end
104
+ radius = Vector[radius[0], radius[1], 0]
105
+ radius.cross(torque) / radius.dot(radius)
106
+ end
107
+
108
+ attr_reader :env
109
+ attr_accessor :radius, :width, :density, :base_friction, :omega_friction
110
+
111
+ def initialize(env)
112
+ @env = env
113
+ @radius = 350/1000r # m
114
+ @width = 200/1000r # m
115
+ @density = DENSITY
116
+ @base_friction = 5/100_000r # constant resistance to rotation
117
+ @omega_friction = 5/100_000r # scales with omega
118
+ yield self if block_given?
119
+ end
120
+
121
+ def to_s
122
+ [[format("%d mm x %d mm (RxW)", @radius * 1000, @width * 1000),
123
+ format("%.1f kg %.2f kg/L", self.mass, @density),
124
+ ].join(" | "),
125
+ ].join("\n")
126
+ end
127
+
128
+ def normal_force
129
+ @normal_force ||= self.mass * @env.g
130
+ @normal_force
131
+ end
132
+
133
+ def alpha(torque, omega: 0, normal_force: nil)
134
+ (torque - self.rotating_friction(omega, normal_force: normal_force)) /
135
+ self.rotational_inertia
136
+ end
137
+
138
+ def implied_torque(alpha)
139
+ alpha * self.rotational_inertia
140
+ end
141
+
142
+ def mass
143
+ self.class.mass(@radius, @width, @density)
144
+ end
145
+
146
+ def mass=(val)
147
+ @density = self.class.density(val, self.volume_l)
148
+ @normal_force = nil # force update
149
+ end
150
+
151
+ # in m^3
152
+ def volume
153
+ self.class.volume(@radius, @width)
154
+ end
155
+
156
+ # in L
157
+ def volume_l
158
+ self.class.volume_l(@radius, @width)
159
+ end
160
+
161
+ def rotational_inertia
162
+ self.class.rotational_inertia(@radius, self.mass)
163
+ end
164
+ alias_method(:moment_of_inertia, :rotational_inertia)
165
+
166
+ def force(axle_torque)
167
+ self.class.force(axle_torque, @radius)
168
+ end
169
+
170
+ def tangential(rotational)
171
+ self.class.tangential(rotational, @radius)
172
+ end
173
+
174
+ # modeled as a tiny but increasing torque opposing omega
175
+ # also scales with normal force
176
+ # maybe not physically faithful but close enough
177
+ def rotating_friction(omega, normal_force: nil)
178
+ return omega if omega.zero?
179
+ normal_force = self.normal_force if normal_force.nil?
180
+ mag = omega.abs
181
+ sign = omega / mag
182
+ -1 * sign * normal_force * (@base_friction + mag * @omega_friction)
183
+ end
184
+ end
185
+ end