driving_physics 0.0.1.2 → 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,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)