driving_physics 0.0.0.3 → 0.0.2.1

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