driving_physics 0.0.2.1 → 0.0.3.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.
@@ -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