driving_physics 0.0.0.3 → 0.0.1.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,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
  #