ballistics-ng 0.1.0.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,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