driving_physics 0.0.1.2 → 0.0.3.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,140 @@
1
+
2
+ # next fun idea: PID controller
3
+
4
+ include DrivingPhysics
5
+
6
+ env = Environment.new
7
+ motor = Motor.new(env)
8
+ puts env
9
+ puts motor
10
+ puts
11
+
12
+ puts "Rev it up!"
13
+ motor.throttle = 1.0
14
+ [:torque, :power].each { |run|
15
+ puts
16
+ puts run.to_s.upcase + ':'
17
+
18
+ 800.upto(7000) { |rpm|
19
+ next unless rpm % 200 == 0
20
+ omega = DrivingPhysics.omega(rpm)
21
+ torque = motor.torque(rpm)
22
+ case run
23
+ when :torque
24
+ count = (torque.to_f / 10).round
25
+ char = 'T'
26
+ val = torque.round.to_s.rjust(5, ' ')
27
+ fmt = "%s Nm"
28
+ when :power
29
+ power = DrivingPhysics.power(torque, omega)
30
+ kw = power.to_f / 1000
31
+ count = (kw / 5).round
32
+ char = 'P'
33
+ val = kw.round(1).to_s.rjust(5, ' ')
34
+ fmt = "%s kW"
35
+ else
36
+ raise "unknown"
37
+ end
38
+ puts format("%s RPM: #{fmt}\t%s",
39
+ rpm.to_s.rjust(4, ' '),
40
+ val,
41
+ char * count)
42
+ }
43
+ }
44
+
45
+ puts
46
+ puts "Now, the simulation begins..."
47
+ puts "---"
48
+ puts "* Spin the motor up to #{motor.idle} RPM with the starter motor."
49
+ puts "* Rev it up with the throttle."
50
+ puts "* Let it die."
51
+ CLI.pause
52
+
53
+ alpha = 0.0
54
+ omega = 0.0
55
+ theta = 0.0
56
+
57
+ duration = 60
58
+
59
+ status = :ignition
60
+ rpm = 0
61
+
62
+ paused = 0.0
63
+ num_ticks = duration * env.hz + 1
64
+ start = Timer.now
65
+
66
+ num_ticks.times { |i|
67
+ # this is an input torque; alpha is determined after inertia and friction
68
+ torque = case status
69
+ when :ignition
70
+ motor.starter_torque
71
+ when :running, :off_throttle, :idling
72
+ motor.torque(rpm)
73
+ else
74
+ 0
75
+ end
76
+
77
+ # Motor#alpha incorporates inertia and friction
78
+ alpha = motor.alpha(torque, omega: omega)
79
+ omega += alpha * env.tick
80
+ theta += omega * env.tick
81
+
82
+ # prevent silly oscillations due to ticks or tiny floating point errors
83
+ omega = 0 if omega < 0.0001
84
+
85
+ net_torque = motor.implied_torque(alpha)
86
+ rpm = DrivingPhysics.rpm(omega)
87
+ power = DrivingPhysics.power(net_torque, omega)
88
+
89
+ if rpm > motor.idle and status == :ignition
90
+ status = :running
91
+ flag = true
92
+ end
93
+
94
+ if rpm > 7000 and status == :running
95
+ status = :off_throttle
96
+ motor.throttle = 0.0
97
+ flag = true
98
+ end
99
+
100
+ if rpm < 1000 and status == :off_throttle
101
+ status = :idling
102
+ motor.throttle = 0.06
103
+ flag = true
104
+ end
105
+
106
+ # we don't need no PID control
107
+ if status == :idling
108
+ if rpm.round <= 999
109
+ motor.throttle += (1.0 - motor.throttle) * 0.005
110
+ elsif rpm.round == 1000
111
+ motor.throttle += (rand - 0.5) * 0.0005
112
+ else
113
+ motor.throttle -= motor.throttle * 0.005
114
+ end
115
+ end
116
+
117
+ if flag or
118
+ (i < 10) or
119
+ (i < 100 and i % 10 == 0) or
120
+ (i < 1000 and i % 100 == 0) or
121
+ (i < 10_000 and i % 500 == 0) or
122
+ (i % 5000 == 0) or
123
+ (status == :idling and i % 100 == 0)
124
+ puts Timer.display(ms: i)
125
+ puts format("Throttle: %.1f%%", motor.throttle * 100)
126
+ puts format("%d RPM %.1f Nm (%d Nm) %.1f kW Friction: %.1f Nm",
127
+ DrivingPhysics.rpm(omega),
128
+ net_torque,
129
+ torque,
130
+ power / 1000,
131
+ motor.spinner.rotating_friction(omega))
132
+ puts format("%.3f rad/s/s %.2f rad/s %.1f rad", alpha, omega, theta)
133
+ puts
134
+
135
+ paused += CLI.pause if flag
136
+ flag = false
137
+ end
138
+ }
139
+
140
+ puts Timer.summary(start, num_ticks, paused)
@@ -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)
@@ -103,7 +112,7 @@ module DrivingPhysics
103
112
  end
104
113
 
105
114
  def corner_mass
106
- Rational(self.total_mass) / @num_tires
115
+ self.total_mass / @num_tires
107
116
  end
108
117
 
109
118
  # per tire
@@ -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
@@ -1,5 +1,5 @@
1
1
  require 'driving_physics/environment'
2
- require 'driving_physics/vector_force'
2
+ #require 'driving_physics/vector_force'
3
3
 
4
4
  module DrivingPhysics
5
5
  # radius is always in meters
@@ -80,41 +80,16 @@ 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
 
111
86
  def initialize(env)
112
87
  @env = env
113
- @radius = 350/1000r # m
114
- @width = 200/1000r # m
88
+ @radius = 0.35
89
+ @width = 0.2
115
90
  @density = DENSITY
116
- @base_friction = 5/100_000r # constant resistance to rotation
117
- @omega_friction = 5/100_000r # scales with omega
91
+ @base_friction = 5.0/100_000 # constant resistance to rotation
92
+ @omega_friction = 5.0/100_000 # scales with omega
118
93
  yield self if block_given?
119
94
  end
120
95
 
@@ -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)