ballistics-ng 0.1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,67 @@
1
+ require 'bigdecimal'
2
+ require 'bigdecimal/util'
3
+
4
+ module Ballistics
5
+ # weight in grains
6
+ # velocity in ft / s
7
+ # acceleration in ft / s^2
8
+ # caliber in inches
9
+
10
+ GRAINS_PER_LB = 7000.to_d
11
+ G = 32.175.to_d # gravitational acceleration
12
+
13
+ def self.lbs(grains)
14
+ grains.to_d / GRAINS_PER_LB
15
+ end
16
+
17
+ def self.grains(lbs)
18
+ lbs.to_d * GRAINS_PER_LB
19
+ end
20
+
21
+ def self.mass(weight)
22
+ weight.to_d / G
23
+ end
24
+
25
+ # Sectional density, according to the SpeerReloading Manual No. 13,
26
+ # is defined as: "A bullet's weight in pounds divided by the square of
27
+ # its diameter in inches." Note that SD is independent of a bullet's
28
+ # shape. All bullets of the same caliber and weight will have the same
29
+ # SD, regardless of their shape or composition.
30
+ #
31
+ def self.sectional_density(weight, caliber)
32
+ self.lbs(weight) / caliber.to_d ** 2
33
+ end
34
+
35
+ # 1/2 m v^2
36
+ #
37
+ def self.kinetic_energy(velocity, weight)
38
+ self.mass(self.lbs(weight)) * velocity.to_d ** 2 / 2
39
+ end
40
+
41
+ # http://www.chuckhawks.com/taylor_KO_factor.htm
42
+ # tl;dr don't use this
43
+ #
44
+ def self.taylor_ko(velocity, weight, caliber)
45
+ self.lbs(weight) * velocity.to_d * caliber.to_d
46
+ end
47
+
48
+ def self.recoil_impulse(proj_weight, charge_weight, proj_v, charge_v=4000)
49
+ self.mass(self.lbs(proj_weight.to_d * proj_v.to_d +
50
+ charge_weight.to_d * charge_v.to_d))
51
+ end
52
+
53
+ def self.free_recoil(proj_weight, charge_weight,
54
+ proj_v, gun_lbs, charge_v=4000)
55
+ self.recoil_impulse(proj_weight, charge_weight, proj_v, charge_v) /
56
+ self.mass(gun_lbs)
57
+ end
58
+
59
+ # 1/2 m(gun) * fr^2
60
+ #
61
+ def self.recoil_energy(proj_weight, charge_weight,
62
+ proj_v, gun_lbs, charge_v=4000)
63
+ fr = self.free_recoil(proj_weight, charge_weight,
64
+ proj_v, gun_lbs, charge_v)
65
+ self.mass(gun_lbs) * fr ** 2 / 2
66
+ end
67
+ end
@@ -0,0 +1,57 @@
1
+ require 'yaml'
2
+
3
+ # We don't depend on the Ballistics module, so it may not have been loaded yet
4
+ module Ballistics; end
5
+
6
+ module Ballistics::YAML
7
+ class UnknownType < RuntimeError; end
8
+ class TypeMismatch < RuntimeError; end
9
+ class LoadError < RuntimeError; end
10
+ class MandatoryFieldError; end
11
+
12
+ # Return a hash keyed by subdir with array values
13
+ # Array contains short names for the subdir's yaml files
14
+ # e.g. { 'cartridges' => ['300_blk'] }
15
+ #
16
+ BUILT_IN = {}
17
+ Dir[File.join(__dir__, '*')].each { |fn|
18
+ if File.directory? fn
19
+ yaml_files = Dir[File.join(fn, '*.yaml')]
20
+ if !yaml_files.empty?
21
+ BUILT_IN[File.basename fn] =
22
+ yaml_files.map { |y| File.basename(y, '.yaml') }
23
+ end
24
+ end
25
+ }
26
+
27
+ def self.load_built_in(dir, short_name)
28
+ files = BUILT_IN[dir] or raise(LoadError, "unknown dir: #{dir}")
29
+ filename = [short_name, 'yaml'].join('.')
30
+ if files.include?(short_name)
31
+ ::YAML.load_file(File.join(__dir__, dir, filename))
32
+ else
33
+ raise(LoadError, "unknown short name: #{short_name}")
34
+ end
35
+ end
36
+
37
+ def self.check_type?(val, type)
38
+ case type
39
+ when :string, :reference
40
+ val.is_a?(String)
41
+ when :float
42
+ val.is_a?(Numeric)
43
+ when :percent
44
+ val.is_a?(Numeric) and val >= 0 and val <= 1
45
+ when :count
46
+ val.is_a?(1.class) and val >= 0
47
+ when :int
48
+ val.is_a?(1.class)
49
+ else
50
+ raise UnknownType, type
51
+ end
52
+ end
53
+
54
+ def self.check_type!(val, type)
55
+ self.check_type?(val, type) or raise(TypeMismatch, [val, type].join(' '))
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ require 'minitest/autorun'
2
+ require 'ballistics/atmosphere'
3
+
4
+ include Ballistics
5
+
6
+ describe Atmosphere do
7
+ before do
8
+ @altitude = 5430
9
+ @humidity = 0.48
10
+ @pressure = 29.93
11
+ @temp = 40
12
+ end
13
+
14
+ it "translates a ballistic coefficient" do
15
+ Atmosphere.translate(0.338,
16
+ altitude: @altitude,
17
+ humidity: @humidity,
18
+ pressure: @pressure,
19
+ temp: @temp).round(3).must_equal 0.392
20
+
21
+ Atmosphere.new("altitude" => @altitude,
22
+ "humidity" => @humidity,
23
+ "pressure" => @pressure,
24
+ "temp" => @temp).translate(0.338).round(3).must_equal 0.392
25
+ end
26
+ end
@@ -0,0 +1,31 @@
1
+ require 'minitest/autorun'
2
+ require 'ballistics'
3
+
4
+ describe Ballistics do
5
+ it "calculates the expected zero angle" do
6
+ opts = {
7
+ drag_number: 1,
8
+ ballistic_coefficient: 0.5,
9
+ velocity: 1200,
10
+ sight_height: 1.6,
11
+ zero_range: 100,
12
+ }
13
+ Ballistics.zero_angle(opts).round(6).must_equal 0.227188
14
+ end
15
+
16
+ it "calculates the expected trajectory" do
17
+ opts = {
18
+ drag_number: 1,
19
+ ballistic_coefficient: 0.5,
20
+ velocity: 2250,
21
+ sight_height: 2.6,
22
+ zero_range: 50,
23
+ wind_speed: 10,
24
+ wind_angle: 90,
25
+ max_range: 1000,
26
+ interval: 25,
27
+ shooting_angle: 0,
28
+ }
29
+ Ballistics.trajectory(opts).must_be_kind_of Array
30
+ end
31
+ end
data/test/cartridge.rb ADDED
@@ -0,0 +1,121 @@
1
+ require 'minitest/autorun'
2
+ require 'ballistics/cartridge'
3
+
4
+ include Ballistics
5
+
6
+ describe Cartridge do
7
+ before do
8
+ @test_data = {
9
+ "name" => "Test Cartridge",
10
+ "case" => "test case",
11
+ "projectile" => "test_projectile",
12
+ "20_inch_fps" => 2000,
13
+ "desc" => "Test Cartridge for test purposes",
14
+ }
15
+ @extra_data = {
16
+ "foo" => "bar",
17
+ }
18
+ end
19
+
20
+ describe "BARREL_LENGTH_REGEX" do
21
+ it "must match and extract barrel lengths" do
22
+ ["16_inch_fps", "1_inch_fps", "21_inch_fps"].each { |valid|
23
+ rgx = Cartridge::BARREL_LENGTH_REGEX
24
+ matches = rgx.match valid
25
+ matches.wont_be_nil
26
+ matches.captures.wont_be_empty
27
+ }
28
+ end
29
+ end
30
+
31
+ describe "new instance" do
32
+ before do
33
+ @cart = Cartridge.new(@test_data)
34
+ @cart_ex = Cartridge.new(@test_data.merge(@extra_data))
35
+ end
36
+
37
+ it "must raise with insufficient parameters" do
38
+ params = {}
39
+ proc { Cartridge.new params }.must_raise Exception
40
+ # Accumulate the mandatory fields in params
41
+ # Note, a field matching Cartridge::BARREL_LENGTH_REGEX is also mandatory
42
+ Cartridge::MANDATORY.keys.each { |mfield|
43
+ params[mfield] = @test_data[mfield]
44
+ proc { Cartridge.new params }.must_raise Exception
45
+ }
46
+ mv = { "15_inch_fps" => 15 }
47
+ proc { Cartridge.new mv }.must_raise Exception
48
+ Cartridge.new(params.merge(mv)).must_be_kind_of Cartridge
49
+ end
50
+
51
+ it "must have a name" do
52
+ @cart.name.wont_be_nil
53
+ @cart.name.must_equal @test_data["name"]
54
+ end
55
+
56
+ it "must have a case" do
57
+ @cart.case.wont_be_nil
58
+ @cart.case.must_equal @test_data["case"]
59
+ end
60
+
61
+ it "must have a projectile" do
62
+ @cart.projectile.wont_be_nil
63
+ @cart.projectile.must_equal @test_data["projectile"]
64
+ end
65
+
66
+ it "must have a muzzle velocity" do
67
+ mv = @cart.muzzle_velocity
68
+ mv.wont_be_nil
69
+ mv.must_be_kind_of Hash
70
+ mv.wont_be_empty
71
+ ["16", "20"].each { |barrel_length|
72
+ test_mv = @test_data[barrel_length]
73
+ if test_mv
74
+ mv[barrel_length].must_equal test_mv
75
+ else
76
+ mv[barrel_length].must_be_nil
77
+ end
78
+ }
79
+ end
80
+
81
+ it "must accept optional fields" do
82
+ Cartridge::OPTIONAL.keys.each { |k|
83
+ if @test_data.key? k
84
+ @cart.send(k).must_equal @test_data[k]
85
+ else
86
+ @cart.send(k).must_be_nil
87
+ end
88
+ }
89
+ end
90
+
91
+ it "must retain initializing data" do
92
+ @cart.yaml_data.must_equal @test_data
93
+ @cart_ex.yaml_data.must_equal @test_data.merge(@extra_data)
94
+ end
95
+
96
+ it "must retain extra data" do
97
+ @cart.extra.must_be_empty
98
+ @cart_ex.extra.wont_be_empty
99
+ @cart_ex.extra.must_equal @extra_data
100
+ end
101
+ end
102
+
103
+ describe "muzzle velocity" do
104
+ before do
105
+ # we need a valid case -- e.g. 300 BLK
106
+ @cart = Cartridge.new(@test_data.merge("case" => "300 BLK"))
107
+ end
108
+
109
+ it "must estimate an unknown muzzle velocity" do
110
+ # sanity checks
111
+ Cartridge::BURN_LENGTH[@cart.case].wont_be_nil
112
+ @test_data["20_inch_fps"].wont_be_nil
113
+ @test_data["19_inch_fps"].must_be_nil
114
+ @test_data["21_inch_fps"].must_be_nil
115
+
116
+ @cart.mv(20).must_equal @test_data["20_inch_fps"]
117
+ @cart.mv(20.9).must_equal @test_data["20_inch_fps"]
118
+ @cart.mv(19.1).must_equal @test_data["20_inch_fps"]
119
+ end
120
+ end
121
+ end
data/test/gun.rb ADDED
@@ -0,0 +1,85 @@
1
+ require 'minitest/autorun'
2
+ require 'ballistics/gun'
3
+
4
+ include Ballistics
5
+
6
+ describe Gun do
7
+ before do
8
+ @test_data = {
9
+ "name" => "Test Gun",
10
+ "chamber" => "test chamber",
11
+ "barrel_length" => 16,
12
+ "sight_height" => 1.5,
13
+ "zero_range" => 100,
14
+ }
15
+
16
+ @extra_data = {
17
+ "foo" => "bar",
18
+ }
19
+ end
20
+
21
+ describe "new instance" do
22
+ before do
23
+ @gun = Gun.new(@test_data)
24
+ @gun_ex = Gun.new(@test_data.merge(@extra_data))
25
+ end
26
+
27
+ it "must have a name" do
28
+ @gun.name.wont_be_nil
29
+ @gun.name.must_equal @test_data["name"]
30
+ end
31
+
32
+ it "must have a chamber" do
33
+ @gun.chamber.wont_be_nil
34
+ @gun.chamber.must_equal @test_data["chamber"]
35
+ end
36
+
37
+ it "must have a barrel_length" do
38
+ @gun.barrel_length.wont_be_nil
39
+ @gun.barrel_length.must_equal @test_data["barrel_length"]
40
+ end
41
+
42
+ it "must have a sight_height" do
43
+ @gun.sight_height.wont_be_nil
44
+ @gun.sight_height.must_equal @test_data["sight_height"]
45
+ end
46
+
47
+ it "must accept optional fields" do
48
+ Gun::OPTIONAL.keys.each { |k|
49
+ if @test_data.key?(k)
50
+ @gun.send(k).must_equal @test_data[k]
51
+ else
52
+ @gun.send(k).must_be_nil
53
+ end
54
+ }
55
+ end
56
+
57
+ it "must retain initializing data" do
58
+ @gun.yaml_data.must_equal @test_data
59
+ @gun_ex.yaml_data.must_equal @test_data.merge(@extra_data)
60
+ end
61
+
62
+ it "must retain extra data" do
63
+ @gun.extra.must_be_empty
64
+ @gun_ex.extra.wont_be_empty
65
+ @gun_ex.extra.must_equal @extra_data
66
+ end
67
+ end
68
+
69
+ describe "cartridges" do
70
+ it "must recognize known cartridges" do
71
+ ["300 BLK"].each { |valid|
72
+ # sanity check
73
+ Gun::CHAMBER_CARTRIDGE.key?(valid).must_equal true
74
+ gun = Gun.new @test_data.merge("chamber" => valid)
75
+ gun.cartridges.must_be_kind_of Hash
76
+ }
77
+ end
78
+ end
79
+
80
+ it "must reject an unknown cartridge" do
81
+ invalid = { "chamber" => "501 Levis" }
82
+ gun = Gun.new @test_data.merge(invalid)
83
+ proc { gun.cartridges }.must_raise Gun::ChamberNotFound
84
+ end
85
+ end
data/test/problem.rb ADDED
@@ -0,0 +1,137 @@
1
+ require 'minitest/autorun'
2
+ require 'ballistics/problem'
3
+
4
+ include Ballistics
5
+
6
+ describe Problem do
7
+ describe "enrich" do
8
+ it "tolerates empty input" do
9
+ hsh = Problem.new.enrich
10
+ hsh.wont_be_empty
11
+ hsh.must_equal Problem::DEFAULTS
12
+ end
13
+
14
+ it "infers projectile params" do
15
+ prj = Projectile.new("name" => "test proj",
16
+ "cal" => 0.223,
17
+ "grains" => 100,
18
+ "g7" => 0.445)
19
+ hsh = Problem.new(projectile: prj).enrich
20
+ hsh.wont_be_empty
21
+ prj.params.each { |sym, val| hsh[sym].must_equal val }
22
+ end
23
+ end
24
+
25
+ describe "trajectory" do
26
+ @problem = Problem.new(atmosphere: Atmosphere.new("altitude" => 5430,
27
+ "humidity" => 0.48,
28
+ "pressure" => 29.93,
29
+ "temp" => 40))
30
+ @opts = {
31
+ drag_number: 1,
32
+ ballistic_coefficient: 0.5,
33
+ velocity: 2850,
34
+ sight_height: 1.6,
35
+ wind_speed: 10,
36
+ wind_angle: 90,
37
+ zero_range: 200,
38
+ max_range: 1000,
39
+ interval: 25,
40
+ }
41
+
42
+ def self.trajectory
43
+ @trajectory ||= @problem.trajectory(@opts)
44
+ end
45
+
46
+ it "raises with insufficient params" do
47
+ proc { Problem.trajectory(drag_function: 'G1',
48
+ drag_number: 1,
49
+ ballistic_coefficient: 0.5,
50
+ velocity: 2850) }.must_raise Exception
51
+ end
52
+
53
+ it "has the expected range" do
54
+ t = self.class.trajectory
55
+ Hash[2 => 50,
56
+ 4 => 100,
57
+ 8 => 200,
58
+ 12 => 300,
59
+ 16 => 400,
60
+ 20 => 500,
61
+ 40 => 1000,
62
+ ].each { |i, val|
63
+ t[i]['range'].must_equal val
64
+ }
65
+ end
66
+
67
+ it "has the expected path" do
68
+ t = self.class.trajectory
69
+ Hash[2 => 0.6,
70
+ 4 => 1.6,
71
+ 8 => 0,
72
+ 12 => -7,
73
+ 16 => -20.1,
74
+ 20 => -40.1,
75
+ 40 => -282.5,
76
+ ].each { |i, val|
77
+ t[i]['path'].round(1).must_equal val
78
+ }
79
+ end
80
+
81
+ it "has the expected velocity" do
82
+ t = self.class.trajectory
83
+ Hash[2 => 2769,
84
+ 4 => 2691,
85
+ 8 => 2538,
86
+ 12 => 2390,
87
+ 16 => 2246,
88
+ 20 => 2108,
89
+ 40 => 1500,
90
+ ].each { |i, val|
91
+ t[i]['velocity'].to_i.must_equal val
92
+ }
93
+ end
94
+
95
+ it "has the expected MOA correction" do
96
+ t = self.class.trajectory
97
+ Hash[2 => -1.1,
98
+ 4 => -1.5,
99
+ 8 => 0,
100
+ 12 => 2.2,
101
+ 16 => 4.8,
102
+ 20 => 7.7,
103
+ 40 => 27,
104
+ ].each { |i, val|
105
+ t[i]['moa_correction'].round(1).must_equal val
106
+ }
107
+ end
108
+
109
+ it "has the expected windage" do
110
+ t = self.class.trajectory
111
+ Hash[2 => 0.2,
112
+ 4 => 0.6,
113
+ 8 => 2.3,
114
+ 12 => 5.2,
115
+ 16 => 9.4,
116
+ 20 => 15.2,
117
+ 40 => 71.3,
118
+ ].each { |i, val|
119
+ t[i]['windage'].round(1).must_equal val
120
+ }
121
+ end
122
+
123
+ it "has the expected MOA correction for windage" do
124
+ t = self.class.trajectory
125
+ Hash[2 => 0.3,
126
+ 4 => 0.5,
127
+ 8 => 1.1,
128
+ 12 => 1.6,
129
+ 16 => 2.3,
130
+ 20 => 2.9,
131
+ 40 => 6.8,
132
+ ].each { |i, val|
133
+ t[i]['moa_windage'].round(1).must_equal val
134
+ }
135
+ end
136
+ end
137
+ end