driving_physics 0.0.0.3 → 0.0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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 = 0.35
114
+ @width = 0.2
115
+ @density = DENSITY
116
+ @base_friction = 5.0/100_000 # constant resistance to rotation
117
+ @omega_friction = 5.0/100_000 # 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
@@ -0,0 +1,109 @@
1
+ require 'driving_physics/disk'
2
+
3
+ module DrivingPhysics
4
+ # Technically speaking, the gear ratio goes up with speed and down with
5
+ # torque. But in the automotive world, it's customary to invert the
6
+ # relationship, where a bigger number means more torque and less speed.
7
+ # We'll store the truth, where the default final drive would be
8
+ # conventionally known as 3.73, but 1/3.73 will be stored.
9
+
10
+ # The gear ratio (above 1) multiplies speed and divides torque
11
+ # 5000 RPM is around 375 MPH with a standard wheel/tire
12
+ # 3.73 final drive reduces that to around 100 mph in e.g. 5th gear (1.0)
13
+ # Likewise, 1st gear is a _smaller_ gear ratio than 3rd
14
+ class Gearbox
15
+ class Disengaged < RuntimeError; end
16
+
17
+ RATIOS = [1/5r, 2/5r, 5/9r, 5/7r, 1r, 5/4r]
18
+ FINAL_DRIVE = 11/41r # 1/3.73
19
+
20
+ attr_accessor :gear, :ratios, :final_drive, :spinner, :fixed_mass
21
+
22
+ def initialize(env)
23
+ @ratios = RATIOS
24
+ @final_drive = FINAL_DRIVE
25
+ @gear = 0 # neutral
26
+
27
+ # represent all rotating mass
28
+ @spinner = Disk.new(env) { |m|
29
+ m.radius = 0.15
30
+ m.base_friction = 5.0/1000
31
+ m.omega_friction = 15.0/100_000
32
+ m.mass = 15
33
+ }
34
+ @fixed_mass = 30 # kg
35
+
36
+ yield self if block_given?
37
+ end
38
+
39
+ # given torque, apply friction, determine alpha
40
+ def alpha(torque, omega: 0)
41
+ @spinner.alpha(torque + @spinner.rotating_friction(omega))
42
+ end
43
+
44
+ def rotating_friction(omega)
45
+ @spinner.rotating_friction(omega)
46
+ end
47
+
48
+ def implied_torque(alpha)
49
+ @spinner.implied_torque(alpha)
50
+ end
51
+
52
+ def mass
53
+ @fixed_mass + @spinner.mass
54
+ end
55
+
56
+ def top_gear
57
+ @ratios.length
58
+ end
59
+
60
+ def to_s
61
+ [format("Ratios: %s", @ratios.inspect),
62
+ format(" Final: %s Mass: %.1f kg Rotating: %.1f kg",
63
+ @final_drive.inspect, self.mass, @spinner.mass),
64
+ ].join("\n")
65
+ end
66
+
67
+ def ratio(gear = nil)
68
+ gear ||= @gear
69
+ raise(Disengaged, "Cannot determine gear ratio") if @gear == 0
70
+ @ratios.fetch(gear - 1) * @final_drive
71
+ end
72
+
73
+ def axle_torque(crank_torque)
74
+ crank_torque / self.ratio
75
+ end
76
+
77
+ def axle_omega(crank_rpm)
78
+ DrivingPhysics.omega(crank_rpm) * self.ratio
79
+ end
80
+
81
+ def crank_rpm(axle_omega)
82
+ DrivingPhysics.rpm(axle_omega) / self.ratio
83
+ end
84
+
85
+ def match_rpms(old_rpm, new_rpm)
86
+ diff = new_rpm - old_rpm
87
+ proportion = diff.to_f / old_rpm
88
+ if proportion.abs < 0.01
89
+ [:ok, proportion]
90
+ elsif proportion.abs < 0.1
91
+ [:slip, proportion]
92
+ elsif @gear == 1 and new_rpm < old_rpm and old_rpm <= 1500
93
+ [:get_rolling, proportion]
94
+ else
95
+ [:mismatch, proportion]
96
+ end
97
+ end
98
+
99
+ def next_gear(rpm, floor: 2500, ceiling: 6400)
100
+ if rpm < floor and @gear > 1
101
+ @gear - 1
102
+ elsif rpm > ceiling and @gear < self.top_gear
103
+ @gear + 1
104
+ else
105
+ @gear
106
+ end
107
+ end
108
+ end
109
+ end
@@ -7,6 +7,7 @@ module DrivingPhysics
7
7
  MPH = (FEET_PER_METER / FEET_PER_MILE) * SECS_PER_HOUR
8
8
  CI_PER_LITER = 61.024
9
9
  GAL_PER_LITER = 0.264172
10
+ PS_PER_KW = 1.3596216173039
10
11
 
11
12
  def self.feet(meters)
12
13
  meters * FEET_PER_METER
@@ -36,6 +37,11 @@ module DrivingPhysics
36
37
  DP::kph(mps(mph))
37
38
  end
38
39
 
40
+ # convert kilowatts to horsepower
41
+ def self.ps(kw)
42
+ kw * PS_PER_KW
43
+ end
44
+
39
45
  def self.deg_c(deg_f)
40
46
  (deg_f - 32).to_f * 5 / 9
41
47
  end
@@ -0,0 +1,150 @@
1
+ require 'driving_physics/disk'
2
+
3
+ module DrivingPhysics
4
+ class Motor
5
+ class Stall < RuntimeError; end
6
+ class OverRev < RuntimeError; end
7
+ class SanityCheck < RuntimeError; end
8
+
9
+ TORQUES = [ 0, 70, 130, 200, 250, 320, 330, 320, 260, 0]
10
+ RPMS = [500, 1000, 1500, 2000, 2500, 3500, 5000, 6000, 7000, 7100]
11
+ ENGINE_BRAKING = 0.2 # 20% of the torque at a given RPM
12
+
13
+ attr_reader :env, :throttle
14
+ attr_accessor :torques, :rpms, :fixed_mass,
15
+ :spinner, :starter_torque, :idle_rpm
16
+
17
+ def initialize(env)
18
+ @env = env
19
+
20
+ @torques = TORQUES
21
+ @rpms = RPMS
22
+ @fixed_mass = 125
23
+
24
+ # represent all rotating mass as one big flywheel
25
+ @spinner = Disk.new(@env) { |fly|
26
+ fly.radius = 0.25 # m
27
+ fly.mass = 75 # kg
28
+ fly.base_friction = 5.0/1000
29
+ fly.omega_friction = 2.0/10_000
30
+ }
31
+ @starter_torque = 500 # Nm
32
+ @idle_rpm = 1000 # RPM
33
+ @throttle = 0.0 # 0.0 - 1.0 (0% - 100%)
34
+ end
35
+
36
+ def to_s
37
+ ary = [format("Throttle: %.1f%%", @throttle * 100)]
38
+ ary << "Torque:"
39
+ @rpms.each_with_index { |r, i|
40
+ ary << format("%s Nm %s RPM",
41
+ @torques[i].round(1).to_s.rjust(4, ' '),
42
+ r.to_s.rjust(5, ' '))
43
+ }
44
+ ary << format("Mass: %.1f kg Fixed: %d kg", self.mass, @fixed_mass)
45
+ ary << format("Rotating: %s", @spinner)
46
+ ary.join("\n")
47
+ end
48
+
49
+ def rotational_inertia
50
+ @spinner.rotational_inertia
51
+ end
52
+
53
+ def mass
54
+ @spinner.mass + @fixed_mass
55
+ end
56
+
57
+ def throttle=(val)
58
+ if val < 0.0 or val > 1.0
59
+ raise(ArgumentError, "val #{val.inspect} should be between 0 and 1")
60
+ end
61
+ @throttle = val
62
+ end
63
+
64
+ # given torque, determine crank alpha after inertia and friction
65
+ def alpha(torque, omega: 0)
66
+ @spinner.alpha(torque + @spinner.rotating_friction(omega))
67
+ end
68
+
69
+ def implied_torque(alpha)
70
+ @spinner.implied_torque(alpha)
71
+ end
72
+
73
+ # interpolate based on torque curve points
74
+ def torque(rpm)
75
+ raise(Stall, "RPM #{rpm}") if rpm < @rpms.first
76
+ raise(OverRev, "RPM #{rpm}") if rpm > @rpms.last
77
+
78
+ last_rpm, last_tq, torque = 99999, -1, nil
79
+
80
+ @rpms.each_with_index { |r, i|
81
+ tq = @torques[i]
82
+ if last_rpm <= rpm and rpm <= r
83
+ proportion = Rational(rpm - last_rpm) / (r - last_rpm)
84
+ torque = last_tq + (tq - last_tq) * proportion
85
+ break
86
+ end
87
+ last_rpm, last_tq = r, tq
88
+ }
89
+ raise(SanityCheck, "RPM #{rpm}") if torque.nil?
90
+
91
+ if (@throttle <= 0.05) and (rpm > @idle_rpm * 1.5)
92
+ # engine braking: 20% of torque
93
+ -1 * torque * ENGINE_BRAKING
94
+ else
95
+ torque * @throttle
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+
102
+ # TODO: starter motor
103
+ # Starter motor is power limited, not torque limited
104
+ # Consider:
105
+ # * 2.2 kW (3.75:1 gear reduction)
106
+ # * 1.8 kW (4.4:1 gear reduction)
107
+ # On a workbench, a starter will draw 80 to 90 amps. However, during actual
108
+ # start-up of an engine, a starter will draw 250 to 350 amps.
109
+ # from https://www.motortrend.com/how-to/because-theres-more-to-a-starter-than-you-realize/
110
+
111
+ # V - Potential, voltage
112
+ # I - Current, amperage
113
+ # R - Resistance, ohms
114
+ # P - Power, wattage
115
+
116
+ # Ohm's law: I = V / R (where R is held constant)
117
+ # P = I * V
118
+ # For resistors (where R is helod constant)
119
+ # = I^2 * R
120
+ # = V^2 / R
121
+
122
+
123
+ # torque proportional to A
124
+ # speed proportional to V
125
+
126
+
127
+ # batteries are rated in terms of CCA - cold cranking amps
128
+
129
+ # P = I * V
130
+ # V = 12 (up to 14ish)
131
+ # I = 300
132
+ # P = 3600 or 3.6 kW
133
+
134
+
135
+ # A starter rated at e.g. 2.2 kW will use more power on initial cranking
136
+ # Sometimes up to 500 amperes are required, and some batteries will provide
137
+ # over 600 cold cranking amps
138
+
139
+
140
+ # Consider:
141
+
142
+ # V = 12
143
+ # R = resistance of battery, wiring, starter motor
144
+ # L = inductance (approx 0)
145
+ # I = current through motor
146
+ # Vc = proportional to omega
147
+
148
+ # rated power - Vc * I
149
+ # input power - V * I
150
+ # input power that is unable to be converted to output power is wasted as heat
@@ -0,0 +1,45 @@
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
+ def self.now
22
+ Time.now
23
+ end
24
+
25
+ def self.since(t)
26
+ self.now - t
27
+ end
28
+
29
+ def self.elapsed(&work)
30
+ t = self.now
31
+ return yield, self.since(t)
32
+ end
33
+
34
+ # HH:MM:SS.mmm
35
+ def self.display(seconds: 0, ms: 0)
36
+ ms += (seconds * 1000).round if seconds > 0
37
+ DrivingPhysics.elapsed_display(ms)
38
+ end
39
+
40
+ def self.summary(start, num_ticks, paused = 0)
41
+ elapsed = self.since(start) - paused
42
+ format("%.3f s (%d ticks/s)", elapsed, num_ticks.to_f / elapsed)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,20 @@
1
+ require 'driving_physics'
2
+
3
+ # Work is Force * Distance (Torque * Theta)
4
+ # W = F * s (W = T * Th)
5
+ # W = T * Theta
6
+
7
+ # Power is Work / time
8
+ # P = W / dt
9
+ # P = T * Th / dt
10
+ # P = T * Omega
11
+
12
+ module DrivingPhysics
13
+ def self.work(force, displacement)
14
+ force * displacement
15
+ end
16
+
17
+ def self.power(force, speed)
18
+ force * speed
19
+ end
20
+ end
@@ -0,0 +1,50 @@
1
+ require 'driving_physics/motor'
2
+ require 'driving_physics/gearbox'
3
+
4
+ module DrivingPhysics
5
+ # Powertrain right now is pretty simple. It combines the motor with the
6
+ # gearbox. It is focused on operations that require or involve both
7
+ # components. It does not pass through operations to the motor or gearbox.
8
+ # Instead, it provides direct access to each component.
9
+ #
10
+ class Powertrain
11
+ attr_reader :motor, :gearbox
12
+
13
+ def initialize(motor, gearbox)
14
+ @motor = motor
15
+ @gearbox = gearbox
16
+ end
17
+
18
+ def mass
19
+ @motor.mass + @gearbox.mass
20
+ end
21
+
22
+ def to_s
23
+ ["\t[MOTOR]", @motor, "\t[GEARBOX]", @gearbox].join("\n")
24
+ end
25
+
26
+ # returns [power, torque, omega]
27
+ def output(rpm)
28
+ t, o = self.axle_torque(rpm), self.axle_omega(rpm)
29
+ [t * o, t, o]
30
+ end
31
+
32
+ def axle_torque(rpm)
33
+ crank_alpha = @motor.alpha(@motor.torque(rpm),
34
+ omega: DrivingPhysics.omega(rpm))
35
+ crank_torque = @motor.implied_torque(crank_alpha)
36
+
37
+ axle_alpha = @gearbox.alpha(@gearbox.axle_torque(crank_torque),
38
+ omega: @gearbox.axle_omega(rpm))
39
+ @gearbox.implied_torque(axle_alpha)
40
+ end
41
+
42
+ def axle_omega(rpm)
43
+ @gearbox.axle_omega(rpm)
44
+ end
45
+
46
+ def crank_rpm(axle_omega)
47
+ @gearbox.crank_rpm(axle_omega)
48
+ end
49
+ end
50
+ end
@@ -18,20 +18,23 @@ module DrivingPhysics
18
18
  # Note: here we only consider speed; we're in a 1D world for now
19
19
  #
20
20
 
21
+ # opposes the direction of speed
21
22
  def self.air_resistance(speed,
22
23
  frontal_area: FRONTAL_AREA,
23
24
  drag_cof: DRAG_COF,
24
25
  air_density: AIR_DENSITY)
25
- 0.5 * frontal_area * drag_cof * air_density * speed ** 2
26
+ -1 * 0.5 * frontal_area * drag_cof * air_density * speed ** 2
26
27
  end
27
28
 
29
+ # opposes the direction of speed
28
30
  def self.rotational_resistance(speed, rot_cof: ROT_COF)
29
- speed * rot_cof
31
+ -1 * speed * rot_cof
30
32
  end
31
33
 
34
+ # opposes the direction of speed
32
35
  # normal force is not always mass * G, e.g. aero downforce
33
36
  def self.rolling_resistance(normal_force, roll_cof: ROLL_COF)
34
- normal_force * roll_cof
37
+ -1 * normal_force * roll_cof
35
38
  end
36
39
 
37
40
  #