driving_physics 0.0.2.1 → 0.0.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,96 +1,204 @@
1
1
  require 'driving_physics/disk'
2
2
 
3
3
  module DrivingPhysics
4
+ def self.interpolate(x, xs:, ys:)
5
+ raise("Xs size #{xs.size}; Ys size #{ys.size}") unless xs.size == ys.size
6
+ raise("#{x} out of range") if x < xs.first or x > xs.last
7
+ xs.each.with_index { |xi, i|
8
+ return ys[i] if x == xi
9
+ if i > 0
10
+ last_x, last_y = xs[i-1], ys[i-1]
11
+ raise("xs out of order (#{xi} <= #{last_x})") unless xi > last_x
12
+ if x <= xi
13
+ proportion = Rational(x - last_x) / (xi - last_x)
14
+ return last_y + (ys[i] - last_y) * proportion
15
+ end
16
+ end
17
+ }
18
+ raise("couldn't find #{x} in #{xs.inspect}") # sanity check
19
+ end
20
+
21
+ class TorqueCurve
22
+ RPMS = [500, 1000, 1500, 2000, 2500, 3500, 5000, 6000, 7000, 7100]
23
+ TORQUES = [ 0, 70, 130, 200, 250, 320, 330, 320, 260, 0]
24
+ RPM_IDX = {
25
+ min: 0,
26
+ idle: 1,
27
+ redline: -2,
28
+ max: -1,
29
+ }
30
+
31
+ def self.validate_rpms!(rpms)
32
+ raise("rpms should be positive") if rpms.any? { |r| r < 0 }
33
+ rpms.each.with_index { |r, i|
34
+ if i > 0 and r <= rpms[i-1]
35
+ raise("rpms #{rpms.inspect} should increase")
36
+ end
37
+ }
38
+ rpms
39
+ end
40
+
41
+ def self.validate_torques!(torques)
42
+ raise("first torque should be zero") unless torques.first == 0
43
+ raise("last torque should be zero") unless torques.last == 0
44
+ raise("torques should be positive") if torques.any? { |t| t < 0 }
45
+ torques
46
+ end
47
+
48
+ def initialize(rpms: RPMS, torques: TORQUES)
49
+ if rpms.size != torques.size
50
+ raise("RPMs size #{rpms.size}; Torques size #{torques.size}")
51
+ end
52
+ @rpms = self.class.validate_rpms! rpms
53
+ @torques = self.class.validate_torques! torques
54
+ peak_torque = 0
55
+ idx = 0
56
+ @torques.each.with_index { |t, i|
57
+ if t > peak_torque
58
+ peak_torque = t
59
+ idx = i
60
+ end
61
+ }
62
+ @peak = idx
63
+ end
64
+
65
+ def peak
66
+ [@rpms[@peak], @torques[@peak]]
67
+ end
68
+
69
+ def to_s
70
+ @rpms.map.with_index { |r, i|
71
+ format("%s RPM %s Nm",
72
+ r.to_s.rjust(5, ' '),
73
+ @torques[i].round(1).to_s.rjust(4, ' '))
74
+ }.join("\n")
75
+ end
76
+
77
+ RPM_IDX.each { |name, idx| define_method(name) do @rpms[idx] end }
78
+
79
+ # interpolate based on torque curve points
80
+ def torque(rpm)
81
+ DrivingPhysics.interpolate(rpm, xs: @rpms, ys: @torques)
82
+ end
83
+ end
84
+
85
+ # represent all rotating mass as one big flywheel
4
86
  class Motor
5
87
  class Stall < RuntimeError; end
6
88
  class OverRev < RuntimeError; end
7
- class SanityCheck < RuntimeError; end
8
89
 
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
90
+ CLOSED_THROTTLE = 0.01 # threshold for engine braking
12
91
 
13
- attr_reader :env, :throttle
14
- attr_accessor :torques, :rpms, :fixed_mass,
15
- :spinner, :starter_torque, :idle_rpm
92
+ # What percentage of the nominal torque at a given RPM would slow the
93
+ # motor when off throttle? 0% at 0 RPM; 50% at 7000 RPM
94
+ def self.engine_braking(rpm)
95
+ (rpm / 14_000.0).clamp(0, 0.5)
96
+ end
16
97
 
17
- def initialize(env)
18
- @env = env
98
+ attr_reader :env, :torque_curve, :throttle
99
+ attr_accessor :fixed_mass, :spinner, :starter_torque
19
100
 
20
- @torques = TORQUES
21
- @rpms = RPMS
22
- @fixed_mass = 125
101
+ # Originally, torque_curve was a kwarg; but mruby currently has a bug
102
+ # where block_given? returns true in the presence of an unset default
103
+ # kwarg, or something like that.
104
+ # https://github.com/mruby/mruby/issues/5741
105
+ #
106
+ def initialize(env, torque_curve = nil)
107
+ @env = env
108
+ @torque_curve = torque_curve.nil? ? TorqueCurve.new : torque_curve
109
+ @throttle = 0.0 # 0.0 - 1.0 (0% - 100%)
23
110
 
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
111
+ @fixed_mass = 125
112
+ @flywheel = Disk.new(@env) { |fly|
113
+ fly.mass = 12
114
+ fly.radius = 0.20
115
+ fly.base_friction = 1.0 / 1_000
116
+ fly.omega_friction = 5.0 / 10_000
117
+ }
118
+ @crankshaft = Disk.new(@env) { |crank|
119
+ crank.mass = 25
120
+ crank.radius = 0.04
121
+ crank.base_friction = 1.0 / 1_000
122
+ crank.omega_friction = 5.0 / 10_000
30
123
  }
124
+
31
125
  @starter_torque = 500 # Nm
32
- @idle_rpm = 1000 # RPM
33
- @throttle = 0.0 # 0.0 - 1.0 (0% - 100%)
126
+
127
+ yield self if block_given?
128
+ end
129
+
130
+ def redline
131
+ @torque_curve.redline
132
+ end
133
+
134
+ def idle
135
+ @torque_curve.idle
34
136
  end
35
137
 
36
138
  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")
139
+ peak_rpm, peak_tq = *@torque_curve.peak
140
+ [format("Peak Torque: %d Nm @ %d RPM Redline: %d",
141
+ peak_tq, peak_rpm, @torque_curve.redline),
142
+ format(" Throttle: %s Mass: %.1f kg (%d kg fixed)",
143
+ self.throttle_pct, self.mass, @fixed_mass),
144
+ format(" Crankshaft: %s", @crankshaft),
145
+ format(" Flywheel: %s", @flywheel),
146
+ ].join("\n")
147
+ end
148
+
149
+ def inertia
150
+ @crankshaft.rotational_inertia + @flywheel.rotational_inertia
151
+ end
152
+
153
+ def energy(omega)
154
+ @crankshaft.energy(omega) + @flywheel.energy(omega)
47
155
  end
48
156
 
49
- def rotational_inertia
50
- @spinner.rotational_inertia
157
+ def friction(omega)
158
+ @crankshaft.rotating_friction(omega) +
159
+ @flywheel.rotating_friction(omega)
51
160
  end
52
161
 
53
162
  def mass
54
- @spinner.mass + @fixed_mass
163
+ @fixed_mass + self.rotating_mass
164
+ end
165
+
166
+ def rotating_mass
167
+ @crankshaft.mass + @flywheel.mass
55
168
  end
56
169
 
57
170
  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
171
+ @throttle = DrivingPhysics.unit_interval! val
172
+ end
173
+
174
+ def throttle_pct(places = 1)
175
+ format("%.#{places}f%%", @throttle * 100)
62
176
  end
63
177
 
64
- # given torque, determine crank alpha after inertia and friction
178
+ # given torque, determine crank alpha considering inertia and friction
65
179
  def alpha(torque, omega: 0)
66
- @spinner.alpha(torque + @spinner.rotating_friction(omega))
180
+ (torque + self.friction(omega)) / self.inertia
67
181
  end
68
182
 
69
183
  def implied_torque(alpha)
70
- @spinner.implied_torque(alpha)
184
+ alpha * self.inertia
71
185
  end
72
186
 
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
187
+ def output_torque(rpm)
188
+ self.torque(rpm) + self.friction(DrivingPhysics.omega(rpm))
189
+ end
77
190
 
78
- last_rpm, last_tq, torque = 99999, -1, nil
191
+ # this is our "input torque" and it depends on @throttle
192
+ # here is where engine braking is implemented
193
+ def torque(rpm)
194
+ raise(Stall, "RPM #{rpm}") if rpm < @torque_curve.min
195
+ raise(OverRev, "RPM #{rpm}") if rpm > @torque_curve.max
79
196
 
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?
197
+ # interpolate based on torque curve points
198
+ torque = @torque_curve.torque(rpm)
90
199
 
91
- if (@throttle <= 0.05) and (rpm > @idle_rpm * 1.5)
92
- # engine braking: 20% of torque
93
- -1 * torque * ENGINE_BRAKING
200
+ if (@throttle <= CLOSED_THROTTLE)
201
+ -1 * torque * self.class.engine_braking(rpm)
94
202
  else
95
203
  torque * @throttle
96
204
  end
@@ -1,3 +1,6 @@
1
+ # This is only intended for use on mruby
2
+ # It is a workaround for Timer's use of Process in cli.rb
3
+ #
1
4
  module DrivingPhysics
2
5
  module CLI
3
6
  # returns user input as a string
@@ -0,0 +1,111 @@
1
+ module DrivingPhysics
2
+ # we will have a control loop
3
+ # SP - setpoint, this is the desired position
4
+ # PV(t) - process variable, this is the sensed position, varying over time
5
+ # e(t) - error, SP - PV
6
+ # CV(t) - control variable: the controller output
7
+
8
+ # for example, where to set the throttle to maintain 1000 RPM
9
+ # SP - 1000 RPM
10
+ # PV - current RPM
11
+ # CV - throttle position
12
+
13
+ class PIDController
14
+ HZ = 1000
15
+ TICK = Rational(1) / HZ
16
+
17
+ # Ziegler-Nichols method for tuning PID gain knobs
18
+ ZN = {
19
+ # Kp Ti Td Ki Kd
20
+ # Var: Ku Tu Tu Ku/Tu Ku*Tu
21
+ 'P' => [0.500],
22
+ 'PI' => [0.450, 0.800, nil, 0.540],
23
+ 'PD' => [0.800, nil, 0.125, nil, 0.100],
24
+ 'PID' => [0.600, 0.500, 0.125, 1.200, 0.075],
25
+ 'PIR' => [0.700, 0.400, 0.150, 1.750, 0.105],
26
+ # less overshoot than standard PID below
27
+ 'some' => [0.333, 0.500, 0.333, 0.666, 0.111],
28
+ 'none' => [0.200, 0.500, 0.333, 0.400, 0.066],
29
+ }
30
+
31
+ # ultimate gain, oscillation
32
+ def self.tune(type, ku, tu)
33
+ record = ZN[type.downcase] || ZN[type.upcase] || ZN.fetch(type)
34
+ kp, ti, td, ki, kd = *record
35
+ kp *= ku if kp
36
+ ti *= tu if ti
37
+ td *= tu if td
38
+ ki *= (ku / tu) if ki
39
+ kd *= (ku * tu) if kd
40
+ { kp: kp, ti: ti, td: td, ki: ki, kd: kd }
41
+ end
42
+
43
+ attr_accessor :kp, :ki, :kd, :dt, :setpoint,
44
+ :p_range, :i_range, :d_range, :o_range
45
+ attr_reader :measure, :error, :last_error, :sum_error
46
+
47
+ def initialize(setpoint, dt: TICK)
48
+ @setpoint, @dt, @measure = setpoint, dt, 0.0
49
+
50
+ # track error over time for integral and derivative
51
+ @error, @last_error, @sum_error = 0.0, 0.0, 0.0
52
+
53
+ # gain / multipliers for PID; tunables
54
+ @kp, @ki, @kd = 1.0, 1.0, 1.0
55
+
56
+ # optional clamps for PID terms and output
57
+ @p_range = (-Float::INFINITY..Float::INFINITY)
58
+ @i_range = (-Float::INFINITY..Float::INFINITY)
59
+ @d_range = (-Float::INFINITY..Float::INFINITY)
60
+ @o_range = (-Float::INFINITY..Float::INFINITY)
61
+
62
+ yield self if block_given?
63
+ end
64
+
65
+ def update(measure)
66
+ self.measure = measure
67
+ self.output
68
+ end
69
+
70
+ def measure=(val)
71
+ @measure = val
72
+ @last_error = @error
73
+ @error = @setpoint - @measure
74
+ if @error * @last_error <= 0 # zero crossing; reset the accumulated error
75
+ @sum_error = @error
76
+ else
77
+ @sum_error += @error
78
+ end
79
+ end
80
+
81
+ def output
82
+ (self.proportion +
83
+ self.integral +
84
+ self.derivative).clamp(@o_range.begin, @o_range.end)
85
+ end
86
+
87
+ def proportion
88
+ (@kp * @error).clamp(@p_range.begin, @p_range.end)
89
+ end
90
+
91
+ def integral
92
+ (@ki * @sum_error * @dt).clamp(@i_range.begin, @i_range.end)
93
+ end
94
+
95
+ def derivative
96
+ (@kd * (@error - @last_error) / @dt).clamp(@d_range.begin, @d_range.end)
97
+ end
98
+
99
+ def to_s
100
+ [format("Setpoint: %.3f Measure: %.3f",
101
+ @setpoint, @measure),
102
+ format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
103
+ @error, @last_error, @sum_error),
104
+ format(" Gain:\t%.3f\t%.3f\t%.3f",
105
+ @kp, @ki, @kd),
106
+ format(" PID:\t%+.3f\t%+.3f\t%+.3f\t= %.5f",
107
+ self.proportion, self.integral, self.derivative, self.output),
108
+ ].join("\n")
109
+ end
110
+ end
111
+ end
@@ -10,7 +10,7 @@ module DrivingPhysics
10
10
  class Powertrain
11
11
  attr_reader :motor, :gearbox
12
12
 
13
- def initialize(motor, gearbox)
13
+ def initialize(motor:, gearbox:)
14
14
  @motor = motor
15
15
  @gearbox = gearbox
16
16
  end
@@ -29,22 +29,17 @@ module DrivingPhysics
29
29
  [t * o, t, o]
30
30
  end
31
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)
32
+ def axle_torque(rpm, axle_omega: nil)
33
+ @gearbox.output_torque(@motor.output_torque(rpm), rpm,
34
+ axle_omega: axle_omega)
40
35
  end
41
36
 
42
- def axle_omega(rpm)
43
- @gearbox.axle_omega(rpm)
37
+ def axle_omega(rpm, axle_omega: nil)
38
+ @gearbox.axle_omega(rpm, axle_omega: axle_omega)
44
39
  end
45
40
 
46
- def crank_rpm(axle_omega)
47
- @gearbox.crank_rpm(axle_omega)
41
+ def crank_rpm(axle_omega, crank_rpm: nil)
42
+ @gearbox.crank_rpm(axle_omega, crank_rpm: crank_rpm)
48
43
  end
49
44
  end
50
45
  end
@@ -23,7 +23,8 @@ module DrivingPhysics
23
23
  frontal_area: FRONTAL_AREA,
24
24
  drag_cof: DRAG_COF,
25
25
  air_density: AIR_DENSITY)
26
- -1 * 0.5 * frontal_area * drag_cof * air_density * speed ** 2
26
+ (speed ** 2) * (speed > 0 ? -1 : 1) *
27
+ 0.5 * frontal_area * drag_cof * air_density
27
28
  end
28
29
 
29
30
  # opposes the direction of speed
@@ -0,0 +1,34 @@
1
+ module DrivingPhysics
2
+ module Timer
3
+ # don't use `defined?` with mruby
4
+ if (Process::CLOCK_MONOTONIC rescue false)
5
+ def self.now
6
+ Process.clock_gettime Process::CLOCK_MONOTONIC
7
+ end
8
+ else
9
+ def self.now
10
+ Time.now
11
+ end
12
+ end
13
+
14
+ def self.since(t)
15
+ self.now - t
16
+ end
17
+
18
+ def self.elapsed(&work)
19
+ t = self.now
20
+ return yield, self.since(t)
21
+ end
22
+
23
+ # HH:MM:SS.mmm
24
+ def self.display(seconds: 0, ms: 0)
25
+ ms += (seconds * 1000).round if seconds > 0
26
+ DrivingPhysics.elapsed_display(ms)
27
+ end
28
+
29
+ def self.summary(start, num_ticks, paused = 0)
30
+ elapsed = self.since(start) - paused
31
+ format("%.3f s (%d ticks/s)", elapsed, num_ticks.to_f / elapsed)
32
+ end
33
+ end
34
+ end
@@ -64,6 +64,29 @@ module DrivingPhysics
64
64
  dir
65
65
  end
66
66
 
67
+ def self.torque_vector(force, radius)
68
+ if !force.is_a?(Vector) or force.size != 2
69
+ raise(ArgumentError, "force must be a 2D vector")
70
+ end
71
+ if !radius.is_a?(Vector) or radius.size != 2
72
+ raise(ArgumentError, "radius must be a 2D vector")
73
+ end
74
+ force = Vector[force[0], force[1], 0]
75
+ radius = Vector[radius[0], radius[1], 0]
76
+ force.cross(radius)
77
+ end
78
+
79
+ def self.force_vector(torque, radius)
80
+ if !torque.is_a?(Vector) or torque.size != 3
81
+ raise(ArgumentError, "torque must be a 3D vector")
82
+ end
83
+ if !radius.is_a?(Vector) or radius.size != 2
84
+ raise(ArgumentError, "radius must be a 2D vector")
85
+ end
86
+ radius = Vector[radius[0], radius[1], 0]
87
+ radius.cross(torque) / radius.dot(radius)
88
+ end
89
+
67
90
  module VectorForce
68
91
  #
69
92
  # Resistance Forces
@@ -1,37 +1,36 @@
1
+ #
2
+ # Units: metric
3
+ #
4
+ # distance: meter
5
+ # velocity: meter / sec
6
+ # acceleration: meter / sec^2
7
+ # volume: Liter
8
+ # temperature: Celsius
9
+ #
1
10
  module DrivingPhysics
2
- #
3
- # Units: metric
4
- #
5
- # distance: meter
6
- # velocity: meter / sec
7
- # acceleration: meter / sec^2
8
- # volume: Liter
9
- # temperature: Celsius
10
- #
11
+ # runtime check; this returns false by default
12
+ # Vector is not currently/easily available in mruby
13
+ def self.has_vector?
14
+ Vector rescue false
15
+ end
11
16
 
12
- #
13
17
  # environmental defaults
14
- #
15
18
  HZ = 1000
16
19
  TICK = Rational(1) / HZ
17
20
  G = 9.8 # m/s^2
18
21
  AIR_TEMP = 25 # deg c
19
22
  AIR_DENSITY = 1.29 # kg / m^3
20
- PETROL_DENSITY = 0.71 # kg / L TODO: move to car.rb
23
+ PETROL_DENSITY = 0.71 # kg / L
21
24
 
22
- #
23
25
  # defaults for resistance forces
24
- #
25
- FRONTAL_AREA = 2.2 # m^2, based roughly on 2000s-era Chevrolet Corvette
26
- DRAG_COF = 0.3 # based roughly on 2000s-era Chevrolet Corvette
27
- DRAG = 0.4257 # air_resistance at 1 m/s given above numbers
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
30
- ROLL_COF = 0.01 # roughly: street tires on concrete
26
+ FRONTAL_AREA = 2.2 # m^2, based roughly on 2000s-era Chevrolet Corvette
27
+ DRAG_COF = 0.3 # based roughly on 2000s-era Chevrolet Corvette
28
+ DRAG = 0.4257 # air_resistance at 1 m/s given above numbers
29
+ ROT_COF = 12.771 # if rot matches air at 30 m/s
30
+ ROT_CONST = 0.05 # N opposing drive force / torque
31
+ ROLL_COF = 0.01 # roughly: street tires on concrete
31
32
 
32
- #
33
33
  # constants
34
- #
35
34
  SECS_PER_MIN = 60
36
35
  MINS_PER_HOUR = 60
37
36
  SECS_PER_HOUR = SECS_PER_MIN * MINS_PER_HOUR
@@ -70,4 +69,11 @@ module DrivingPhysics
70
69
  alias_method(:omega, :accum)
71
70
  alias_method(:theta, :accum)
72
71
  end
72
+
73
+ def self.unit_interval!(val)
74
+ if val < 0.0 or val > 1.0
75
+ raise(ArgumentError, "val #{val.inspect} should be between 0 and 1")
76
+ end
77
+ val
78
+ end
73
79
  end