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.
@@ -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