driving_physics 0.0.0.3 → 0.0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,184 @@
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)
134
+ torque / self.rotational_inertia
135
+ end
136
+
137
+ def implied_torque(alpha)
138
+ alpha * self.rotational_inertia
139
+ end
140
+
141
+ def mass
142
+ self.class.mass(@radius, @width, @density)
143
+ end
144
+
145
+ def mass=(val)
146
+ @density = self.class.density(val, self.volume_l)
147
+ @normal_force = nil # force update
148
+ end
149
+
150
+ # in m^3
151
+ def volume
152
+ self.class.volume(@radius, @width)
153
+ end
154
+
155
+ # in L
156
+ def volume_l
157
+ self.class.volume_l(@radius, @width)
158
+ end
159
+
160
+ def rotational_inertia
161
+ self.class.rotational_inertia(@radius, self.mass)
162
+ end
163
+ alias_method(:moment_of_inertia, :rotational_inertia)
164
+
165
+ def force(axle_torque)
166
+ self.class.force(axle_torque, @radius)
167
+ end
168
+
169
+ def tangential(rotational)
170
+ self.class.tangential(rotational, @radius)
171
+ end
172
+
173
+ # modeled as a tiny but increasing torque opposing omega
174
+ # also scales with normal force
175
+ # maybe not physically faithful but close enough
176
+ def rotating_friction(omega, normal_force: nil)
177
+ return omega if omega.zero?
178
+ mag = omega.abs
179
+ sign = omega / mag
180
+ -1 * sign * (normal_force || self.normal_force) *
181
+ (@base_friction + @omega_friction * mag)
182
+ end
183
+ end
184
+ 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/1000r
31
+ m.omega_friction = 15/100_000r
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
@@ -0,0 +1,99 @@
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, 50, 130, 200, 250, 320, 330, 320, 260, 0]
10
+ RPMS = [500, 1000, 1500, 2000, 2500, 3500, 5000, 6000, 7000, 7100]
11
+
12
+ attr_reader :env
13
+ attr_accessor :torques, :rpms, :fixed_mass,
14
+ :spinner, :starter_torque, :idle_rpm
15
+
16
+ def initialize(env)
17
+ @env = env
18
+
19
+ @torques = TORQUES
20
+ @rpms = RPMS
21
+ @fixed_mass = 125
22
+
23
+ # represent all rotating mass as one big flywheel
24
+ @spinner = Disk.new(@env) { |fly|
25
+ fly.radius = 0.25 # m
26
+ fly.mass = 75 # kg
27
+ fly.base_friction = 5/1000r
28
+ fly.omega_friction = 2/10_000r
29
+ }
30
+ @starter_torque = 500 # Nm
31
+ @idle_rpm = 1000 # RPM
32
+ end
33
+
34
+ def to_s
35
+ ary = ["Torque:"]
36
+ @rpms.each_with_index { |r, i|
37
+ ary << format("%s Nm %s RPM",
38
+ @torques[i].round(1).to_s.rjust(4, ' '),
39
+ r.to_s.rjust(5, ' '))
40
+ }
41
+ ary << format("Mass: %.1f kg Fixed: %d kg", self.mass, @fixed_mass)
42
+ ary << format("Rotating: %s", @spinner)
43
+ ary.join("\n")
44
+ end
45
+
46
+ def rotational_inertia
47
+ @spinner.rotational_inertia
48
+ end
49
+
50
+ def mass
51
+ @spinner.mass + @fixed_mass
52
+ end
53
+
54
+ # given torque, determine crank alpha after inertia and friction
55
+ def alpha(torque, omega: 0)
56
+ @spinner.alpha(torque + @spinner.rotating_friction(omega))
57
+ end
58
+
59
+ def implied_torque(alpha)
60
+ @spinner.implied_torque(alpha)
61
+ end
62
+
63
+ # How much torque is required to accelerate spinner up to alpha,
64
+ # overcoming both inertia and friction
65
+ # Presumably we have more input torque available, but this will be
66
+ # used to do more work than just spinning up the motor
67
+ #
68
+ #def resistance_torque(alpha, omega)
69
+ # # reverse sign on inertial_torque as it is not modeled as a resistance
70
+ # -1 * @spinner.inertial_torque(alpha) +
71
+ # @spinner.rotating_friction(omega)
72
+ #end
73
+
74
+ # interpolate based on torque curve points
75
+ def torque(rpm)
76
+ raise(Stall, "RPM #{rpm}") if rpm < @rpms.first
77
+ raise(OverRev, "RPM #{rpm}") if rpm > @rpms.last
78
+
79
+ last_rpm = 99999
80
+ last_tq = -1
81
+
82
+ # ew; there must be a better way
83
+ @rpms.each_with_index { |r, i|
84
+ tq = @torques[i]
85
+ if last_rpm <= rpm and rpm <= r
86
+ proportion = Rational(rpm - last_rpm) / (r - last_rpm)
87
+ return last_tq + (tq - last_tq) * proportion
88
+ end
89
+ last_rpm = r
90
+ last_tq = tq
91
+ }
92
+ raise(SanityCheck, "RPM #{rpm}")
93
+ end
94
+
95
+ def net_torque(rpm, alpha: 0)
96
+ torque(rpm) + resistance_torque(alpha, DrivingPhysics.omega(rpm))
97
+ end
98
+ end
99
+ 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,39 @@
1
+ require 'driving_physics/motor'
2
+ require 'driving_physics/gearbox'
3
+
4
+ module DrivingPhysics
5
+ class Powertrain
6
+ attr_reader :motor, :gearbox
7
+
8
+ def initialize(motor, gearbox)
9
+ @motor = motor
10
+ @gearbox = gearbox
11
+ end
12
+
13
+ def to_s
14
+ ["\t[MOTOR]", @motor, "\t[GEARBOX]", @gearbox].join("\n")
15
+ end
16
+
17
+ def select_gear(gear)
18
+ @gearbox.gear = gear
19
+ end
20
+
21
+ def axle_torque(rpm)
22
+ crank_alpha = @motor.alpha(@motor.torque(rpm),
23
+ omega: DrivingPhysics.omega(rpm))
24
+ crank_torque = @motor.implied_torque(crank_alpha)
25
+
26
+ axle_alpha = @gearbox.alpha(@gearbox.axle_torque(crank_torque),
27
+ omega: @gearbox.axle_omega(rpm))
28
+ @gearbox.implied_torque(axle_alpha)
29
+ end
30
+
31
+ def axle_omega(rpm)
32
+ @gearbox.axle_omega(rpm)
33
+ end
34
+
35
+ def crank_rpm(axle_omega)
36
+ @gearbox.crank_rpm(axle_omega)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,120 @@
1
+ require 'driving_physics/disk'
2
+
3
+ module DrivingPhysics
4
+
5
+ # a Tire is a Disk with lighter density and meaningful surface friction
6
+
7
+ class Tire < Disk
8
+ # Note, this is not the density of solid rubber. This density
9
+ # yields a sensible mass for a wheel / tire combo at common radius
10
+ # and width, assuming a uniform density
11
+ # e.g. 25kg at 350mm R x 200mm W
12
+ #
13
+ DENSITY = 0.325 # kg / L
14
+
15
+ # * the traction force opposes the axle torque / drive force
16
+ # thus, driving the car forward
17
+ # * if the drive force exceeds the traction force, slippage occurs
18
+ # * slippage reduces the available traction force further
19
+ # * if the drive force is not reduced, the slippage increases
20
+ # until resistance forces equal the drive force
21
+ def self.traction(normal_force, cof)
22
+ normal_force * cof
23
+ end
24
+
25
+ attr_accessor :mu_s, :mu_k, :omega_friction, :base_friction, :roll_cof
26
+
27
+ def initialize(env)
28
+ @env = env
29
+ @radius = 350/1000r # m
30
+ @width = 200/1000r # m
31
+ @density = DENSITY
32
+ @temp = @env.air_temp
33
+ @mu_s = 11/10r # static friction
34
+ @mu_k = 7/10r # kinetic friction
35
+ @base_friction = 5/10_000r
36
+ @omega_friction = 5/100_000r # scales with speed
37
+ @roll_cof = DrivingPhysics::ROLL_COF
38
+
39
+ yield self if block_given?
40
+ end
41
+
42
+ def to_s
43
+ [[format("%d mm x %d mm (RxW)", @radius * 1000, @width * 1000),
44
+ format("%.1f kg %.1f C", self.mass, @temp),
45
+ format("cF: %.1f / %.1f", @mu_s, @mu_k),
46
+ ].join(" | "),
47
+ ].join("\n")
48
+ end
49
+
50
+ def wear!(amount)
51
+ @radius -= amount
52
+ end
53
+
54
+ def heat!(amount_deg_c)
55
+ @temp += amount_deg_c
56
+ end
57
+
58
+ def traction(nf, static: true)
59
+ self.class.traction(nf, static ? @mu_s : @mu_k)
60
+ end
61
+
62
+ # require a normal_force to be be passed in
63
+ def rotating_friction(omega, normal_force:)
64
+ super(omega, normal_force: normal_force)
65
+ end
66
+
67
+ # rolling loss in terms of axle torque
68
+ def rolling_friction(omega, normal_force:)
69
+ return omega if omega.zero?
70
+ mag = omega.abs
71
+ sign = omega / mag
72
+ -1 * sign * (normal_force * @roll_cof) * @radius
73
+ end
74
+
75
+ # inertial loss in terms of axle torque when used as a drive wheel
76
+ def inertial_loss(axle_torque, driven_mass:)
77
+ drive_force = self.force(axle_torque)
78
+ force_loss = 0
79
+ # The force loss depends on the acceleration, but the acceleration
80
+ # depends on the force loss. Converge the value via 5 round trips.
81
+ # This is a rough way to compute an integral and should be accurate
82
+ # to 8+ digits.
83
+ 5.times {
84
+ acc = DrivingPhysics.acc(drive_force - force_loss, driven_mass)
85
+ alpha = acc / @radius
86
+ force_loss = self.inertial_torque(alpha) / @radius
87
+ }
88
+ force_loss * @radius
89
+ end
90
+
91
+ def net_torque(axle_torque, mass:, omega:, normal_force:)
92
+ # friction forces oppose omega
93
+ net = axle_torque +
94
+ self.rolling_friction(omega, normal_force: normal_force) +
95
+ self.rotating_friction(omega, normal_force: normal_force)
96
+
97
+ # inertial loss has interdependencies; calculate last
98
+ # it opposes net torque, not omega
99
+ sign = net / net.abs
100
+ net - sign * self.inertial_loss(net, driven_mass: mass)
101
+ end
102
+
103
+ def net_tractable_torque(axle_torque,
104
+ mass:, omega:, normal_force:, static: true)
105
+ net = self.net_torque(axle_torque,
106
+ mass: mass,
107
+ omega: omega,
108
+ normal_force: normal_force)
109
+ tt = self.tractable_torque(normal_force, static: static)
110
+ net > tt ? tt : net
111
+ end
112
+
113
+ # this doesn't take inertial losses or internal frictional losses
114
+ # into account. input torque required to saturate traction will be
115
+ # higher than what this method returns
116
+ def tractable_torque(nf, static: true)
117
+ traction(nf, static: static) * @radius
118
+ end
119
+ end
120
+ end
@@ -84,12 +84,25 @@ module DrivingPhysics
84
84
  frontal_area: FRONTAL_AREA,
85
85
  drag_cof: DRAG_COF,
86
86
  air_density: AIR_DENSITY)
87
+ return velocity if velocity.zero?
87
88
  -1 * 0.5 * frontal_area * drag_cof * air_density *
88
89
  velocity * velocity.magnitude
89
90
  end
90
91
 
91
- def self.rotational_resistance(velocity, rot_cof: ROT_COF)
92
- -1 * velocity * rot_cof
92
+ # return a force opposing velocity, representing friction / hysteresis
93
+ def self.rotational_resistance(velocity,
94
+ rot_const: ROT_CONST,
95
+ rot_cof: ROT_COF)
96
+ return velocity if velocity.zero?
97
+ -1 * velocity * rot_cof + -1 * velocity.normalize * rot_const
98
+ end
99
+
100
+ # return a torque opposing omega, representing friction / hysteresis
101
+ def self.omega_resistance(omega,
102
+ rot_const: ROT_TQ_CONST,
103
+ rot_cof: ROT_TQ_COF)
104
+ return 0 if omega == 0.0
105
+ omega * ROT_TQ_COF + ROT_TQ_CONST
93
106
  end
94
107
 
95
108
  # dir is drive_force vector or velocity vector; will be normalized
@@ -26,6 +26,7 @@ module DrivingPhysics
26
26
  DRAG_COF = 0.3 # based roughly on 2000s-era Chevrolet Corvette
27
27
  DRAG = 0.4257 # air_resistance at 1 m/s given above numbers
28
28
  ROT_COF = 12.771 # if rotating resistance matches air resistance at 30 m/s
29
+ ROT_CONST = 0.05 # N opposing drive force / torque
29
30
  ROLL_COF = 0.01 # roughly: street tires on concrete
30
31
 
31
32
  #