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,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