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.
@@ -0,0 +1,143 @@
1
+ require 'device_control'
2
+ require 'driving_physics/motor'
3
+ require 'driving_physics/cli'
4
+
5
+ include DrivingPhysics
6
+
7
+ env = Environment.new { |e|
8
+ e.hz = CLI.prompt("What frequency? (Hz)", default: 1000).to_i
9
+ }
10
+ motor = Motor.new(env)
11
+ puts env
12
+ puts motor
13
+ puts
14
+
15
+
16
+ PID = DeviceControl::PIDController
17
+ models = PID::ZN.keys.join(' ')
18
+
19
+ puts "PID models: #{models}"
20
+ pid_model = CLI.prompt("What PID model?", default: 'none')
21
+
22
+ # Ku for 1000 RPM idle has been measured at 0.2
23
+ # Tu is 1000 Hz / 2
24
+ zn = PID.tune(pid_model, 0.2, 1/500r)
25
+ puts format("kp: %.3f\tki: %.1f\tkd: %.8f", zn[:kp], zn[:ki], zn[:kd])
26
+ puts
27
+
28
+ # maintain arbitrary RPM via throttle adjustment
29
+ pidc = DeviceControl::PIDController.new(motor.idle, dt: env.tick) { |p|
30
+ p.kp = zn[:kp]
31
+ p.ki = zn[:ki]
32
+ p.kd = zn[:kd]
33
+ p.p_range = (-1.00..1.00)
34
+ p.i_range = (-0.50..0.50)
35
+ p.d_range = (-0.25..0.25)
36
+ p.o_range = ( 0.00..1.00)
37
+ p.e_range = (-1.00..1.00)
38
+ }
39
+ # track the accumulated absolute error
40
+ pidc_error = 0.0
41
+
42
+ puts pidc
43
+ puts
44
+
45
+ duration = CLI.prompt("How long to run for? (seconds)", default: 10).to_f
46
+ CLI.pause
47
+
48
+ alpha = 0.0
49
+ omega = 0.0
50
+
51
+ rpm = 0
52
+ status = :ignition
53
+
54
+ (duration * env.hz + 1).to_i.times { |i|
55
+ puts Timer.display(seconds: i.to_f / env.hz)
56
+
57
+ # GIVEN:
58
+ # * rpm
59
+ # DETERMINE:
60
+ # * torque
61
+ # * new rpm
62
+
63
+ case status
64
+ when :ignition
65
+ torque = motor.starter_torque
66
+ puts "IGNITION: Starter torque %d Nm %d RPM" % [torque, rpm]
67
+ status = :running if rpm > motor.idle
68
+ when :running
69
+ torque = motor.torque(rpm)
70
+ puts format("RUNNING: %.1f Nm @ %d RPM Throttle: %s",
71
+ torque, rpm.round, motor.throttle_pct(3))
72
+ puts pidc
73
+ puts "Absolute error %.3f" % pidc_error
74
+ end
75
+
76
+ # update kinematics
77
+ alpha = motor.alpha(torque, omega: omega)
78
+ omega += alpha * env.tick
79
+ rpm = DrivingPhysics.rpm(omega)
80
+
81
+ puts format("Net Torque: %+.4f Nm Friction: %+.4f Nm",
82
+ motor.implied_torque(alpha),
83
+ motor.friction(omega))
84
+
85
+ case status
86
+ when :ignition
87
+ # ok
88
+ CLI.pause if i % 50 == 0
89
+ when :running
90
+ # Update throttle position and RPM
91
+ # Throttle position is based on PID controller
92
+ # PID controller looks at current RPM
93
+ # Update to the new RPM at the very end of the loop
94
+
95
+ motor.throttle = pidc.update(rpm)
96
+ pidc_error += pidc.error.abs
97
+ error_pct = pidc.error.abs / pidc.setpoint.to_f
98
+
99
+ # prompt every so often
100
+ if (error_pct < 0.001) or
101
+ (i < 10_000 and i % 100 == 0) or
102
+ (i % 1000 == 0)
103
+ # ask about PID tunables; loop until an acceptable answer
104
+ loop {
105
+ puts
106
+ puts format("rpm %.1f\tsetpoint %d\tkp %.3f\tki %.3f\tkd %.3f",
107
+ rpm, pidc.setpoint, pidc.kp, pidc.ki, pidc.kd)
108
+ str = CLI.prompt("Enter key and value, e.g. > setpoint 3500\n")
109
+ # empty answer is perfectly acceptable; exit the loop
110
+ break if str.empty?
111
+
112
+ # look for "key value" pairs
113
+ parts = str.split(' ').map(&:strip)
114
+ next unless parts.size == 2
115
+ begin
116
+ key, val = parts[0].downcase, parts[1].to_f
117
+ rescue => e
118
+ puts e
119
+ next
120
+ end
121
+
122
+ # update RPM or PID controller and exit the loop
123
+ if key == "rpm"
124
+ rpm = val
125
+ omega = DrivingPhysics.omega(rpm)
126
+ else
127
+ begin
128
+ pidc.send("#{key}=", val)
129
+ rescue => e
130
+ puts e
131
+ next
132
+ end
133
+ end
134
+ # reset error tracking on successful input
135
+ pidc_error = 0.0
136
+ pidc.sum_error = 0.0
137
+ puts
138
+ break
139
+ }
140
+ end
141
+ end
142
+ puts
143
+ }
data/demo/powertrain.rb CHANGED
@@ -8,7 +8,7 @@ include DrivingPhysics
8
8
  env = Environment.new
9
9
  motor = Motor.new(env)
10
10
  gearbox = Gearbox.new(env)
11
- powertrain = Powertrain.new(motor, gearbox)
11
+ powertrain = Powertrain.new(motor: motor, gearbox: gearbox)
12
12
  motor.throttle = 1.0
13
13
  puts env
14
14
  puts powertrain
@@ -1,12 +1,13 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'driving_physics'
3
- s.summary = "WIP"
3
+ s.summary = "Physics from first principles: mass, friction, rotation, etc"
4
4
  s.description = "WIP"
5
5
  s.authors = ["Rick Hull"]
6
6
  s.homepage = "https://github.com/rickhull/driving_physics"
7
7
  s.license = "LGPL-3.0"
8
8
 
9
9
  s.required_ruby_version = "> 2"
10
+ s.add_runtime_dependency 'device_control', '~> 0.3'
10
11
 
11
12
  s.version = File.read(File.join(__dir__, 'VERSION')).chomp
12
13
 
@@ -26,6 +26,14 @@ module DrivingPhysics
26
26
  @powertrain.motor.throttle = val
27
27
  end
28
28
 
29
+ def clutch
30
+ @powertrain.gearbox.clutch
31
+ end
32
+
33
+ def clutch=(val)
34
+ @powertrain.gearbox.clutch = val
35
+ end
36
+
29
37
  def gear
30
38
  @powertrain.gearbox.gear
31
39
  end
@@ -38,9 +46,9 @@ module DrivingPhysics
38
46
  @powertrain.gearbox.top_gear
39
47
  end
40
48
 
41
- # force opposing speed
49
+ # force opposing speed; depends on speed**2 but use speed and speed.abs
42
50
  def air_force(speed)
43
- -0.5 * @frontal_area * @cd * @env.air_density * speed ** 2
51
+ -0.5 * @frontal_area * @cd * @env.air_density * speed * speed.abs
44
52
  end
45
53
 
46
54
  # force of opposite sign to omega
@@ -60,6 +68,7 @@ module DrivingPhysics
60
68
  # force of opposite sign to force
61
69
  def tire_inertial_force(force)
62
70
  mag = force.abs
71
+ return 0.0 if mag < 0.001
63
72
  sign = force / mag
64
73
  force_loss = 0
65
74
  5.times {
@@ -86,8 +95,8 @@ module DrivingPhysics
86
95
  ].join("\n")
87
96
  end
88
97
 
89
- def drive_force(rpm)
90
- @tire.force(@powertrain.axle_torque(rpm))
98
+ def drive_force(rpm, axle_omega: nil)
99
+ @tire.force @powertrain.axle_torque(rpm, axle_omega: axle_omega)
91
100
  end
92
101
 
93
102
  def tire_speed(rpm)
@@ -1,10 +1,14 @@
1
+ require 'driving_physics/timer'
2
+
1
3
  module DrivingPhysics
2
4
  module CLI
3
5
  # returns user input as a string
4
- def self.prompt(msg = '')
5
- print msg + ' ' unless msg.empty?
6
+ def self.prompt(msg = '', default: nil)
7
+ print "#{msg} " unless msg.empty?
8
+ print "(#{default}) " unless default.nil?
6
9
  print '> '
7
- $stdin.gets.chomp
10
+ input = $stdin.gets.chomp
11
+ input.empty? ? default.to_s : input
8
12
  end
9
13
 
10
14
  # press Enter to continue, ignore input, return elapsed time
@@ -16,36 +20,4 @@ module DrivingPhysics
16
20
  Timer.since(t)
17
21
  end
18
22
  end
19
-
20
- module Timer
21
- if defined? Process::CLOCK_MONOTONIC
22
- def self.now
23
- Process.clock_gettime Process::CLOCK_MONOTONIC
24
- end
25
- else
26
- def self.now
27
- Time.now
28
- end
29
- end
30
-
31
- def self.since(t)
32
- self.now - t
33
- end
34
-
35
- def self.elapsed(&work)
36
- t = self.now
37
- return yield, self.since(t)
38
- end
39
-
40
- # HH:MM:SS.mmm
41
- def self.display(seconds: 0, ms: 0)
42
- ms += (seconds * 1000).round if seconds > 0
43
- DrivingPhysics.elapsed_display(ms)
44
- end
45
-
46
- def self.summary(start, num_ticks, paused = 0)
47
- elapsed = self.since(start) - paused
48
- format("%.3f s (%d ticks/s)", elapsed, num_ticks.to_f / elapsed)
49
- end
50
- end
51
23
  end
@@ -0,0 +1,167 @@
1
+ require 'driving_physics/car'
2
+
3
+ module DrivingPhysics
4
+ class Cockpit
5
+ CLUTCH_MIN = 0.1
6
+ REV_MATCH = 0.02
7
+ REV_SLIP = 0.1
8
+ MODE = {
9
+ normal: 0,
10
+ sport: 1,
11
+ sport_plus: 2,
12
+ }
13
+ MODE_LABELS = MODE.invert
14
+
15
+ def self.mode_label(idx)
16
+ MODE_LABELS.fetch(idx)
17
+ end
18
+
19
+ # return [:status, recommended clutch, proportional difference]
20
+ def self.rev_match(old_rpm, new_rpm)
21
+ diff = new_rpm - old_rpm
22
+ proportion = diff.to_f / old_rpm
23
+ clutch = [1.0 - proportion.abs, CLUTCH_MIN].max
24
+ if proportion.abs <= REV_MATCH
25
+ [:match, 1.0, proportion]
26
+ elsif proportion.abs <= REV_SLIP
27
+ [:slip, clutch, proportion]
28
+ else
29
+ [:mismatch, clutch, proportion]
30
+ end
31
+ end
32
+
33
+ def self.generate
34
+ e = Environment.new
35
+ t = Tire.new(e)
36
+ m = Motor.new(e)
37
+ g = Gearbox.new(e)
38
+ p = Powertrain.new(motor: m, gearbox: g)
39
+ c = Car.new(tire: t, powertrain: p)
40
+ self.new(c)
41
+ end
42
+
43
+ attr_reader :car, :brake_pedal, :steering_wheel, :mode, :mode_label
44
+ attr_accessor :min_rpm, :max_rpm
45
+
46
+ def initialize(car)
47
+ @car = car
48
+ @brake_pedal = 0.0 # to 1.0
49
+ @steering_wheel = 0.0 # -1.0 to +1.0
50
+ @min_rpm = @car.powertrain.motor.idle + 1000
51
+ @max_rpm = @car.powertrain.motor.redline
52
+
53
+ self.mode = 0 # normal
54
+
55
+ yield self if block_given?
56
+ end
57
+
58
+ def clutch_pedal
59
+ 1.0 - @car.clutch
60
+ end
61
+
62
+ def clutch_pct
63
+ self.clutch_pedal * 100
64
+ end
65
+
66
+ # clutch pedal has inverse relationship with the clutch itself
67
+ # clutch pedal DISENGAGES the clutch; pedal at 0.0 means clutch at 1.0
68
+ def clutch_pedal=(val)
69
+ @car.clutch = 1.0 - DrivingPhysics.unit_interval!(val)
70
+ end
71
+
72
+ def brake_pct
73
+ self.brake_pedal * 100
74
+ end
75
+
76
+ # TODO: implement @car.brake or something
77
+ def brake_pedal=(val)
78
+ @brake_pedal = DrivingPhysics.unit_interval!(val)
79
+ end
80
+
81
+ def throttle_pedal
82
+ @car.throttle
83
+ end
84
+
85
+ def throttle_pct
86
+ self.throttle_pedal * 100
87
+ end
88
+
89
+ def throttle_pedal=(val)
90
+ @car.throttle = DrivingPhysics.unit_interval!(val)
91
+ end
92
+
93
+ def steering_pct
94
+ self.steering_wheel * 100
95
+ end
96
+
97
+ # TODO: implement @car.steering_wheel
98
+ def steering_wheel=(val)
99
+ DrivingPhysics.unit_interval!(val.abs)
100
+ @steering_wheel = val
101
+ end
102
+
103
+ def gear
104
+ @car.gear
105
+ end
106
+
107
+ def gear=(val)
108
+ @car.gear = val
109
+ end
110
+
111
+ def mode=(val)
112
+ @mode = MODE_LABELS[val] ? val : MODE.fetch(val)
113
+ @mode_label = Cockpit.mode_label(@mode)
114
+ end
115
+
116
+ def to_s
117
+ [format("Clutch: %d%% Brake: %d%% Throttle: %d%%",
118
+ self.clutch_pct, self.brake_pct, self.throttle_pct),
119
+ format("Wheel: %d%% Gear: %d Mode: [%d] %s",
120
+ self.steering_pct, self.gear, self.mode, self.mode_label)
121
+ ].join("\n")
122
+ end
123
+
124
+ # return :normal, :sport, :sport_plus based on pedal positions
125
+ def pedal_mode
126
+ if self.brake_pedal < 0.4 and self.throttle_pedal < 0.5
127
+ MODE[:normal]
128
+ elsif self.brake_pedal < 0.7 and self.throttle_pedal < 0.8
129
+ MODE[:sport]
130
+ else
131
+ MODE[:sport_plus]
132
+ end
133
+ end
134
+
135
+ def rpm_range
136
+ if @mode < MODE[:sport_plus] and self.pedal_mode == MODE[:sport_plus]
137
+ mode = @mode + 1
138
+ elsif @mode == MODE[:sport_plus] and self.pedal_mode == MODE[:normal]
139
+ mode = MODE[:sport]
140
+ else
141
+ mode = @mode
142
+ end
143
+
144
+ case mode
145
+ when MODE[:normal]
146
+ [@min_rpm, [@min_rpm + 3000, @max_rpm - 1000].min]
147
+ when MODE[:sport]
148
+ [@min_rpm + 1000, @max_rpm - 1000]
149
+ when MODE[:sport_plus]
150
+ [@min_rpm + 1500, @max_rpm]
151
+ end
152
+ end
153
+
154
+ def choose_gear(rpm)
155
+ min, max = *self.rpm_range
156
+ gear = self.gear
157
+
158
+ if rpm < min and gear > 1
159
+ gear - 1
160
+ elsif rpm > max and gear < @car.top_gear
161
+ gear + 1
162
+ else
163
+ gear
164
+ end
165
+ end
166
+ end
167
+ end
@@ -80,31 +80,6 @@ module DrivingPhysics
80
80
  tangential.to_f / radius
81
81
  end
82
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
83
  attr_reader :env
109
84
  attr_accessor :radius, :width, :density, :base_friction, :omega_friction
110
85
 
@@ -130,6 +105,11 @@ module DrivingPhysics
130
105
  @normal_force
131
106
  end
132
107
 
108
+ # E = (1/2) (I) (omega^2)
109
+ def energy(omega)
110
+ 0.5 * self.rotational_inertia * omega ** 2
111
+ end
112
+
133
113
  def alpha(torque, omega: 0, normal_force: nil)
134
114
  (torque - self.rotating_friction(omega, normal_force: normal_force)) /
135
115
  self.rotational_inertia
@@ -11,6 +11,8 @@ module DrivingPhysics
11
11
  @air_temp = AIR_TEMP
12
12
  @air_density = AIR_DENSITY
13
13
  @petrol_density = PETROL_DENSITY
14
+
15
+ yield self if block_given?
14
16
  end
15
17
 
16
18
  def hz=(int)
@@ -13,16 +13,30 @@ module DrivingPhysics
13
13
  # Likewise, 1st gear is a _smaller_ gear ratio than 3rd
14
14
  class Gearbox
15
15
  class Disengaged < RuntimeError; end
16
+ class ClutchDisengage < Disengaged; end
16
17
 
17
18
  RATIOS = [1/5r, 2/5r, 5/9r, 5/7r, 1r, 5/4r]
18
19
  FINAL_DRIVE = 11/41r # 1/3.73
20
+ REVERSE = -1
21
+ REVERSE_RATIO = -1/10r
19
22
 
20
- attr_accessor :gear, :ratios, :final_drive, :spinner, :fixed_mass
23
+ attr_accessor :ratios, :final_drive, :spinner, :fixed_mass
24
+ attr_reader :gear, :clutch
25
+
26
+ def self.gear_interval!(gear, min: REVERSE, max:)
27
+ if gear < min or gear > max
28
+ raise(ArgumentError, format("gear %s should be between %d and %d",
29
+ gear.inspect, min, max))
30
+ end
31
+ raise(ArgumentError, "gear should be an integer") if !gear.is_a? Integer
32
+ gear
33
+ end
21
34
 
22
35
  def initialize(env)
23
36
  @ratios = RATIOS
24
37
  @final_drive = FINAL_DRIVE
25
- @gear = 0 # neutral
38
+ @gear = 0 # neutral
39
+ @clutch = 1.0 # fully engaged (clutch pedal out)
26
40
 
27
41
  # represent all rotating mass
28
42
  @spinner = Disk.new(env) { |m|
@@ -36,6 +50,10 @@ module DrivingPhysics
36
50
  yield self if block_given?
37
51
  end
38
52
 
53
+ def clutch=(val)
54
+ @clutch = DrivingPhysics.unit_interval! val
55
+ end
56
+
39
57
  # given torque, apply friction, determine alpha
40
58
  def alpha(torque, omega: 0)
41
59
  @spinner.alpha(torque + @spinner.rotating_friction(omega))
@@ -53,57 +71,69 @@ module DrivingPhysics
53
71
  @fixed_mass + @spinner.mass
54
72
  end
55
73
 
74
+ def gear=(val)
75
+ @gear = self.class.gear_interval!(val, max: self.top_gear)
76
+ end
77
+
56
78
  def top_gear
57
79
  @ratios.length
58
80
  end
59
81
 
60
82
  def to_s
61
- [format("Ratios: %s", @ratios.inspect),
83
+ [self.inputs,
84
+ format("Ratios: %s", @ratios.inspect),
62
85
  format(" Final: %s Mass: %.1f kg Rotating: %.1f kg",
63
86
  @final_drive.inspect, self.mass, @spinner.mass),
64
87
  ].join("\n")
65
88
  end
66
89
 
90
+ def inputs
91
+ format("Gear: %d Clutch: %.1f%%", @gear, @clutch * 100)
92
+ end
93
+
67
94
  def ratio(gear = nil)
68
95
  gear ||= @gear
69
- raise(Disengaged, "Cannot determine gear ratio") if @gear == 0
70
- @ratios.fetch(gear - 1) * @final_drive
96
+ case gear
97
+ when REVERSE
98
+ REVERSE_RATIO * @final_drive
99
+ when 0
100
+ raise(Disengaged, "Cannot determine gear ratio")
101
+ else
102
+ @ratios.fetch(gear - 1) * @final_drive
103
+ end
71
104
  end
72
105
 
73
106
  def axle_torque(crank_torque)
74
- crank_torque / self.ratio
107
+ crank_torque * @clutch / self.ratio
75
108
  end
76
109
 
77
- def axle_omega(crank_rpm)
78
- DrivingPhysics.omega(crank_rpm) * self.ratio
110
+ def output_torque(crank_torque, crank_rpm, axle_omega: nil)
111
+ axle_alpha = self.alpha(self.axle_torque(crank_torque),
112
+ omega: self.axle_omega(crank_rpm,
113
+ axle_omega: axle_omega))
114
+ self.implied_torque(axle_alpha)
79
115
  end
80
116
 
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]
117
+ # take into account the old axle_omega and @clutch
118
+ def axle_omega(crank_rpm, axle_omega: nil)
119
+ new_axle_omega = DrivingPhysics.omega(crank_rpm) * self.ratio
120
+ if axle_omega.nil?
121
+ raise(ClutchDisengage, "cannot determine axle omega") if @clutch != 1.0
122
+ return new_axle_omega
96
123
  end
124
+ diff = new_axle_omega - axle_omega
125
+ axle_omega + diff * @clutch
97
126
  end
98
127
 
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
128
+ # take into account the old crank_rpm and @clutch
129
+ # crank will tolerate mismatch more than axle
130
+ def crank_rpm(axle_omega, crank_rpm: nil)
131
+ new_crank_rpm = DrivingPhysics.rpm(axle_omega) / self.ratio
132
+ if crank_rpm.nil?
133
+ raise(ClutchDisengage, "cannot determine crank rpm") if @clutch != 1.0
134
+ return new_crank_rpm
106
135
  end
136
+ crank_rpm + (new_crank_rpm - crank_rpm) * @clutch
107
137
  end
108
138
  end
109
139
  end