ballistics-engine 0.21.5 → 0.22.11

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.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +57 -184
  3. data/Cargo.toml +2 -2
  4. data/README.md +91 -136
  5. data/lib/ballistics_engine.rb +2 -0
  6. data/src/lib.rs +379 -117
  7. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64e76dd334d5a80138bcfe9ab08d1e0d9358b71ae3c34220cdb6e2ae3ceea60d
4
- data.tar.gz: cc6b187d3976badb38ab9443473fce8a84a1cd5ecb2810b9beb7857910fffbdd
3
+ metadata.gz: 7fd3109e80b8fb96d2647e072eea4f8060caa3df7e661823879362caa732775b
4
+ data.tar.gz: 48a989637d61de7da186012d14af798611eb3803ed80f209f80294e6ed3c2b32
5
5
  SHA512:
6
- metadata.gz: e9011fb8edd7dc0a311bcb215bed350839ac9975d6badd8e707f0ebd75cec3511e71142dc8ef993ba8134531d419eb444d2cafa0d511dd7311d638f6bbf169db
7
- data.tar.gz: 1ab985a9b2eea40d2ad36392dba7a832be98e395cdd8ae58f44b83c72a541a2ce446e3f1ed5b4a164d3b55e1ca4abae37c49f82ea946dc6f0e287a262fb2f1e2
6
+ metadata.gz: e2d28d89a751928ba6b36f81150765ea94b81f050078ddba105bd287d41d27838cb32d6cda89c964cedae21bf49ed3aff7b7f573052d06b98f6e9a013f12331a
7
+ data.tar.gz: a49d7bab609e3077481659ad5180d36290d5f24e23519acd53ead7ccbca0467fa4008500c33f535df63cc8c58540261648ba700054f5c9b88bb98dcb2b037c87
data/Cargo.lock CHANGED
@@ -93,15 +93,15 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
93
93
 
94
94
  [[package]]
95
95
  name = "ballistics-engine"
96
- version = "0.21.5"
96
+ version = "0.22.11"
97
97
  source = "registry+https://github.com/rust-lang/crates.io-index"
98
- checksum = "163bc907032a1ebe17f623608facb2dd9d2ad0f005faa69a91f7055bf3356c46"
98
+ checksum = "76e5814dcee860b6d400ef5bd432833afad4750328ab3666befe73fa2fe0eae4"
99
99
  dependencies = [
100
100
  "clap",
101
101
  "clap_complete",
102
102
  "csv",
103
103
  "dirs",
104
- "getrandom 0.3.4",
104
+ "getrandom 0.4.3",
105
105
  "js-sys",
106
106
  "nalgebra",
107
107
  "ndarray",
@@ -121,7 +121,7 @@ dependencies = [
121
121
 
122
122
  [[package]]
123
123
  name = "ballistics_engine_rb"
124
- version = "0.21.5"
124
+ version = "0.22.11"
125
125
  dependencies = [
126
126
  "ballistics-engine",
127
127
  "magnus",
@@ -193,6 +193,17 @@ version = "1.0.4"
193
193
  source = "registry+https://github.com/rust-lang/crates.io-index"
194
194
  checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
195
195
 
196
+ [[package]]
197
+ name = "chacha20"
198
+ version = "0.10.1"
199
+ source = "registry+https://github.com/rust-lang/crates.io-index"
200
+ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
201
+ dependencies = [
202
+ "cfg-if",
203
+ "cpufeatures 0.3.0",
204
+ "rand_core",
205
+ ]
206
+
196
207
  [[package]]
197
208
  name = "clang-sys"
198
209
  version = "1.8.1"
@@ -268,6 +279,15 @@ dependencies = [
268
279
  "libc",
269
280
  ]
270
281
 
282
+ [[package]]
283
+ name = "cpufeatures"
284
+ version = "0.3.0"
285
+ source = "registry+https://github.com/rust-lang/crates.io-index"
286
+ checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
287
+ dependencies = [
288
+ "libc",
289
+ ]
290
+
271
291
  [[package]]
272
292
  name = "crc32fast"
273
293
  version = "1.5.0"
@@ -446,113 +466,41 @@ dependencies = [
446
466
 
447
467
  [[package]]
448
468
  name = "getrandom"
449
- version = "0.3.4"
469
+ version = "0.4.3"
450
470
  source = "registry+https://github.com/rust-lang/crates.io-index"
451
- checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
471
+ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
452
472
  dependencies = [
453
473
  "cfg-if",
454
474
  "js-sys",
455
475
  "libc",
456
476
  "r-efi",
457
- "wasip2",
477
+ "rand_core",
458
478
  "wasm-bindgen",
459
479
  ]
460
480
 
461
481
  [[package]]
462
482
  name = "glam"
463
- version = "0.14.0"
464
- source = "registry+https://github.com/rust-lang/crates.io-index"
465
- checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da"
466
-
467
- [[package]]
468
- name = "glam"
469
- version = "0.15.2"
470
- source = "registry+https://github.com/rust-lang/crates.io-index"
471
- checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b"
472
-
473
- [[package]]
474
- name = "glam"
475
- version = "0.16.0"
476
- source = "registry+https://github.com/rust-lang/crates.io-index"
477
- checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16"
478
-
479
- [[package]]
480
- name = "glam"
481
- version = "0.17.3"
482
- source = "registry+https://github.com/rust-lang/crates.io-index"
483
- checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776"
484
-
485
- [[package]]
486
- name = "glam"
487
- version = "0.18.0"
488
- source = "registry+https://github.com/rust-lang/crates.io-index"
489
- checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34"
490
-
491
- [[package]]
492
- name = "glam"
493
- version = "0.19.0"
494
- source = "registry+https://github.com/rust-lang/crates.io-index"
495
- checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca"
496
-
497
- [[package]]
498
- name = "glam"
499
- version = "0.20.5"
500
- source = "registry+https://github.com/rust-lang/crates.io-index"
501
- checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f"
502
-
503
- [[package]]
504
- name = "glam"
505
- version = "0.21.3"
506
- source = "registry+https://github.com/rust-lang/crates.io-index"
507
- checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815"
508
-
509
- [[package]]
510
- name = "glam"
511
- version = "0.22.0"
512
- source = "registry+https://github.com/rust-lang/crates.io-index"
513
- checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774"
514
-
515
- [[package]]
516
- name = "glam"
517
- version = "0.23.0"
518
- source = "registry+https://github.com/rust-lang/crates.io-index"
519
- checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c"
520
-
521
- [[package]]
522
- name = "glam"
523
- version = "0.24.2"
524
- source = "registry+https://github.com/rust-lang/crates.io-index"
525
- checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945"
526
-
527
- [[package]]
528
- name = "glam"
529
- version = "0.25.0"
530
- source = "registry+https://github.com/rust-lang/crates.io-index"
531
- checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3"
532
-
533
- [[package]]
534
- name = "glam"
535
- version = "0.27.0"
483
+ version = "0.30.10"
536
484
  source = "registry+https://github.com/rust-lang/crates.io-index"
537
- checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9"
485
+ checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
538
486
 
539
487
  [[package]]
540
488
  name = "glam"
541
- version = "0.28.0"
489
+ version = "0.31.1"
542
490
  source = "registry+https://github.com/rust-lang/crates.io-index"
543
- checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94"
491
+ checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7"
544
492
 
545
493
  [[package]]
546
494
  name = "glam"
547
- version = "0.29.3"
495
+ version = "0.32.1"
548
496
  source = "registry+https://github.com/rust-lang/crates.io-index"
549
- checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
497
+ checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
550
498
 
551
499
  [[package]]
552
500
  name = "glam"
553
- version = "0.30.10"
501
+ version = "0.33.2"
554
502
  source = "registry+https://github.com/rust-lang/crates.io-index"
555
- checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
503
+ checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436"
556
504
 
557
505
  [[package]]
558
506
  name = "glob"
@@ -713,27 +661,15 @@ dependencies = [
713
661
 
714
662
  [[package]]
715
663
  name = "nalgebra"
716
- version = "0.34.1"
664
+ version = "0.35.0"
717
665
  source = "registry+https://github.com/rust-lang/crates.io-index"
718
- checksum = "c4d5b3eff5cd580f93da45e64715e8c20a3996342f1e466599cf7a267a0c2f5f"
666
+ checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346"
719
667
  dependencies = [
720
668
  "approx",
721
- "glam 0.14.0",
722
- "glam 0.15.2",
723
- "glam 0.16.0",
724
- "glam 0.17.3",
725
- "glam 0.18.0",
726
- "glam 0.19.0",
727
- "glam 0.20.5",
728
- "glam 0.21.3",
729
- "glam 0.22.0",
730
- "glam 0.23.0",
731
- "glam 0.24.2",
732
- "glam 0.25.0",
733
- "glam 0.27.0",
734
- "glam 0.28.0",
735
- "glam 0.29.3",
736
669
  "glam 0.30.10",
670
+ "glam 0.31.1",
671
+ "glam 0.32.1",
672
+ "glam 0.33.2",
737
673
  "matrixmultiply",
738
674
  "nalgebra-macros",
739
675
  "num-complex",
@@ -860,12 +796,6 @@ version = "0.2.0"
860
796
  source = "registry+https://github.com/rust-lang/crates.io-index"
861
797
  checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
862
798
 
863
- [[package]]
864
- name = "paste"
865
- version = "1.0.15"
866
- source = "registry+https://github.com/rust-lang/crates.io-index"
867
- checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
868
-
869
799
  [[package]]
870
800
  name = "pest"
871
801
  version = "2.8.5"
@@ -936,15 +866,6 @@ dependencies = [
936
866
  "portable-atomic",
937
867
  ]
938
868
 
939
- [[package]]
940
- name = "ppv-lite86"
941
- version = "0.2.21"
942
- source = "registry+https://github.com/rust-lang/crates.io-index"
943
- checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
944
- dependencies = [
945
- "zerocopy",
946
- ]
947
-
948
869
  [[package]]
949
870
  name = "proc-macro2"
950
871
  version = "1.0.106"
@@ -978,44 +899,32 @@ dependencies = [
978
899
 
979
900
  [[package]]
980
901
  name = "r-efi"
981
- version = "5.3.0"
902
+ version = "6.0.0"
982
903
  source = "registry+https://github.com/rust-lang/crates.io-index"
983
- checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
904
+ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
984
905
 
985
906
  [[package]]
986
907
  name = "rand"
987
- version = "0.9.4"
908
+ version = "0.10.2"
988
909
  source = "registry+https://github.com/rust-lang/crates.io-index"
989
- checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
910
+ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
990
911
  dependencies = [
991
- "rand_chacha",
992
- "rand_core",
993
- ]
994
-
995
- [[package]]
996
- name = "rand_chacha"
997
- version = "0.9.0"
998
- source = "registry+https://github.com/rust-lang/crates.io-index"
999
- checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1000
- dependencies = [
1001
- "ppv-lite86",
912
+ "chacha20",
913
+ "getrandom 0.4.3",
1002
914
  "rand_core",
1003
915
  ]
1004
916
 
1005
917
  [[package]]
1006
918
  name = "rand_core"
1007
- version = "0.9.5"
919
+ version = "0.10.1"
1008
920
  source = "registry+https://github.com/rust-lang/crates.io-index"
1009
- checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1010
- dependencies = [
1011
- "getrandom 0.3.4",
1012
- ]
921
+ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
1013
922
 
1014
923
  [[package]]
1015
924
  name = "rand_distr"
1016
- version = "0.5.1"
925
+ version = "0.6.0"
1017
926
  source = "registry+https://github.com/rust-lang/crates.io-index"
1018
- checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
927
+ checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
1019
928
  dependencies = [
1020
929
  "num-traits",
1021
930
  "rand",
@@ -1137,9 +1046,9 @@ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
1137
1046
 
1138
1047
  [[package]]
1139
1048
  name = "safe_arch"
1140
- version = "0.7.4"
1049
+ version = "1.0.0"
1141
1050
  source = "registry+https://github.com/rust-lang/crates.io-index"
1142
- checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
1051
+ checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed"
1143
1052
  dependencies = [
1144
1053
  "bytemuck",
1145
1054
  ]
@@ -1211,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1211
1120
  checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1212
1121
  dependencies = [
1213
1122
  "cfg-if",
1214
- "cpufeatures",
1123
+ "cpufeatures 0.2.17",
1215
1124
  "digest",
1216
1125
  ]
1217
1126
 
@@ -1229,14 +1138,13 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1229
1138
 
1230
1139
  [[package]]
1231
1140
  name = "simba"
1232
- version = "0.9.1"
1141
+ version = "0.10.0"
1233
1142
  source = "registry+https://github.com/rust-lang/crates.io-index"
1234
- checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95"
1143
+ checksum = "8f45c644a9f3a386f9288625d9f0c1e999e1acf07a37df35d0516c7f199d9cb2"
1235
1144
  dependencies = [
1236
1145
  "approx",
1237
1146
  "num-complex",
1238
1147
  "num-traits",
1239
- "paste",
1240
1148
  "wide",
1241
1149
  ]
1242
1150
 
@@ -1325,15 +1233,6 @@ version = "0.11.1+wasi-snapshot-preview1"
1325
1233
  source = "registry+https://github.com/rust-lang/crates.io-index"
1326
1234
  checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1327
1235
 
1328
- [[package]]
1329
- name = "wasip2"
1330
- version = "1.0.4+wasi-0.2.12"
1331
- source = "registry+https://github.com/rust-lang/crates.io-index"
1332
- checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
1333
- dependencies = [
1334
- "wit-bindgen",
1335
- ]
1336
-
1337
1236
  [[package]]
1338
1237
  name = "wasm-bindgen"
1339
1238
  version = "0.2.108"
@@ -1405,9 +1304,9 @@ dependencies = [
1405
1304
 
1406
1305
  [[package]]
1407
1306
  name = "wide"
1408
- version = "0.7.33"
1307
+ version = "1.5.0"
1409
1308
  source = "registry+https://github.com/rust-lang/crates.io-index"
1410
- checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
1309
+ checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89"
1411
1310
  dependencies = [
1412
1311
  "bytemuck",
1413
1312
  "safe_arch",
@@ -1428,32 +1327,6 @@ dependencies = [
1428
1327
  "windows-link",
1429
1328
  ]
1430
1329
 
1431
- [[package]]
1432
- name = "wit-bindgen"
1433
- version = "0.57.1"
1434
- source = "registry+https://github.com/rust-lang/crates.io-index"
1435
- checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
1436
-
1437
- [[package]]
1438
- name = "zerocopy"
1439
- version = "0.8.33"
1440
- source = "registry+https://github.com/rust-lang/crates.io-index"
1441
- checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
1442
- dependencies = [
1443
- "zerocopy-derive",
1444
- ]
1445
-
1446
- [[package]]
1447
- name = "zerocopy-derive"
1448
- version = "0.8.33"
1449
- source = "registry+https://github.com/rust-lang/crates.io-index"
1450
- checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
1451
- dependencies = [
1452
- "proc-macro2",
1453
- "quote",
1454
- "syn",
1455
- ]
1456
-
1457
1330
  [[package]]
1458
1331
  name = "zip"
1459
1332
  version = "6.0.0"
data/Cargo.toml CHANGED
@@ -3,7 +3,7 @@
3
3
  # rake-compiler/rb-sys cargo-metadata lookup resolves (newer rb-sys does not
4
4
  # normalize hyphens). The published gem name is set in the gemspec, independent.
5
5
  name = "ballistics_engine_rb"
6
- version = "0.21.5"
6
+ version = "0.22.11"
7
7
  edition = "2021"
8
8
  authors = ["Alex Jokela <email@tinycomputers.io>"]
9
9
  description = "Ruby bindings for ballistics-engine"
@@ -15,7 +15,7 @@ name = "ballistics_engine_rb"
15
15
  crate-type = ["cdylib"]
16
16
 
17
17
  [dependencies]
18
- ballistics-engine = { version = "0.21.5", default-features = false }
18
+ ballistics-engine = { version = "0.22.11", default-features = false }
19
19
  magnus = "0.8"
20
20
 
21
21
  [profile.release]
data/README.md CHANGED
@@ -33,165 +33,120 @@ $ gem install ballistics-engine
33
33
 
34
34
  ## Quick Start
35
35
 
36
+ The API is hash-in / hash-out — pass a hash of imperial-unit parameters, get a hash back.
37
+
36
38
  ```ruby
37
39
  require 'ballistics_engine'
38
40
 
39
- # Create ballistic inputs (168gr .308 Winchester)
40
- inputs = BallisticsEngine::BallisticInputs.new(
41
- 0.223, # BC (G7)
42
- 168.0, # bullet weight (grains)
43
- 2650.0, # muzzle velocity (fps)
44
- 0.308, # bullet diameter (inches)
45
- 1.2, # bullet length (inches)
46
- 1.5, # sight height (inches)
47
- 100.0, # zero distance (yards)
48
- 0.0, # shooting angle (degrees)
49
- 11.25, # twist rate (inches)
50
- true # right-hand twist
51
- )
52
-
53
- # Optional: Add wind conditions
54
- wind = BallisticsEngine::WindConditions.new(
55
- 10.0, # speed (mph)
56
- 90.0 # direction (degrees, 90 = from right)
57
- )
58
-
59
- # Optional: Add atmospheric conditions
60
- atmosphere = BallisticsEngine::AtmosphericConditions.new(
61
- 59.0, # temperature (F)
62
- 29.92, # pressure (inHg)
63
- 50.0, # humidity (%)
64
- 0.0 # altitude (feet)
41
+ # 168gr .308 with a 10 mph crosswind
42
+ result = BallisticsEngine.solve(
43
+ "bc" => 0.223, # G7 BC
44
+ "bullet_weight_grains" => 168.0,
45
+ "muzzle_velocity_fps" => 2650.0,
46
+ "bullet_diameter_inches" => 0.308,
47
+ "bullet_length_inches" => 1.2,
48
+ "sight_height_inches" => 1.5,
49
+ "zero_distance_yards" => 100.0,
50
+ "drag_model" => "G7", # G1 | G7 | G8
51
+ "wind" => { "speed_mph" => 10.0, "direction_degrees" => 90.0 },
52
+ "atmosphere" => { "temperature_f" => 59.0, "pressure_inhg" => 29.92,
53
+ "humidity_percent" => 50.0, "altitude_feet" => 0.0 }
65
54
  )
66
55
 
67
- # Create solver and calculate trajectory
68
- solver = BallisticsEngine::TrajectorySolver.new(inputs, wind, atmosphere)
69
- result = solver.solve
70
-
71
- # Access results
72
- puts "Max range: #{result.max_range_yards.round(1)} yards"
73
- puts "Time of flight: #{result.time_of_flight.round(3)} seconds"
74
- puts "Impact velocity: #{result.impact_velocity_fps.round(1)} fps"
75
- puts "Impact energy: #{result.impact_energy_ftlbs.round(1)} ft-lbs"
76
-
77
- # Iterate through trajectory points
78
- result.points.each do |point|
79
- puts "Range: #{point.x.round(1)}yd, Drop: #{point.y.round(2)}yd, Velocity: #{point.velocity_fps.round(1)}fps"
56
+ puts "Max range: #{result['max_range_yards'].round(1)} yd"
57
+ puts "Time of flight: #{result['time_of_flight'].round(3)} s"
58
+ puts "Impact velocity: #{result['impact_velocity_fps'].round(1)} fps"
59
+ result["points"].each do |p|
60
+ puts " range #{p['x'].round(1)}yd drop #{p['y'].round(2)}yd vel #{p['velocity_fps'].round(1)}fps"
80
61
  end
81
62
  ```
82
63
 
83
- ## API Reference
84
-
85
- ### Classes
64
+ ### Velocity-dependent BC (`bc_segments`)
86
65
 
87
- #### `BallisticInputs`
66
+ Supply your own velocity:BC ladder (velocities in fps). The BC applies while the bullet's
67
+ current speed is in `[velocity_min_fps, velocity_max_fps)`:
88
68
 
89
- Ballistic calculation parameters.
90
-
91
- **Constructor:**
92
69
  ```ruby
93
- BallisticInputs.new(
94
- bc, # Ballistic coefficient
95
- bullet_weight_grains, # Bullet weight in grains
96
- muzzle_velocity_fps, # Muzzle velocity in fps
97
- bullet_diameter_inches, # Bullet diameter in inches
98
- bullet_length_inches, # Bullet length in inches
99
- sight_height_inches, # Sight height in inches
100
- zero_distance_yards, # Zero distance in yards
101
- shooting_angle_degrees, # Shooting angle in degrees
102
- twist_rate_inches, # Barrel twist rate in inches
103
- is_right_twist # Right-hand twist? (boolean)
104
- )
70
+ BallisticsEngine.solve(inputs.merge(
71
+ "use_bc_segments" => true,
72
+ "bc_segments_data" => [
73
+ { "velocity_min_fps" => 1800.0, "velocity_max_fps" => 4000.0, "bc" => 0.243 },
74
+ { "velocity_min_fps" => 1500.0, "velocity_max_fps" => 1800.0, "bc" => 0.228 },
75
+ { "velocity_min_fps" => 1200.0, "velocity_max_fps" => 1500.0, "bc" => 0.205 }
76
+ ]
77
+ ))
78
+ # Also accepts [vmin_fps, vmax_fps, bc] triples.
105
79
  ```
106
80
 
107
- **Attributes:**
108
- - All constructor parameters are accessible as read/write attributes
81
+ ### Segmented wind (`wind_segments`)
109
82
 
110
- #### `WindConditions`
83
+ Distance-keyed wind (independent of the velocity-keyed BC above) — e.g. downrange sensors:
111
84
 
112
- Wind parameters.
113
-
114
- **Constructor:**
115
85
  ```ruby
116
- WindConditions.new(
117
- speed_mph, # Wind speed in mph
118
- direction_degrees # Wind direction in degrees (0=headwind, 90=from right)
119
- )
86
+ BallisticsEngine.solve(inputs.merge(
87
+ # [speed_mph, angle_degrees, until_yards] (angle 90 = from the right)
88
+ "wind_segments" => [[5.0, 90.0, 150.0], [7.0, 90.0, 300.0], [9.0, 90.0, 1000.0]]
89
+ ))
120
90
  ```
121
91
 
122
- **Attributes:**
123
- - `speed_mph` - Wind speed in mph (read/write)
124
- - `direction_degrees` - Wind direction in degrees (read/write)
92
+ ### Zero angle and Monte Carlo
125
93
 
126
- #### `AtmosphericConditions`
127
-
128
- Atmospheric parameters.
129
-
130
- **Constructor:**
131
94
  ```ruby
132
- AtmosphericConditions.new(
133
- temperature_f, # Temperature in Fahrenheit
134
- pressure_inhg, # Pressure in inches of mercury
135
- humidity_percent, # Relative humidity (0-100)
136
- altitude_feet # Altitude in feet
137
- )
95
+ z = BallisticsEngine.calculate_zero_angle(inputs.merge("target_distance_yards" => 1000.0))
96
+ puts "zero: #{z['zero_angle_moa'].round(2)} MOA"
97
+
98
+ mc = BallisticsEngine.monte_carlo(inputs.merge(
99
+ "num_simulations" => 1000,
100
+ "velocity_std_dev_fps" => 10.0,
101
+ "bc_std_dev" => 0.01,
102
+ "target_distance_yards"=> 1000.0
103
+ ))
104
+ puts "hit probability: #{(mc['hit_probability'] * 100).round(1)}%"
138
105
  ```
139
106
 
140
- **Attributes:**
141
- - `temperature_f` - Temperature in Fahrenheit (read/write)
142
- - `pressure_inhg` - Pressure in inHg (read/write)
143
- - `humidity_percent` - Humidity percentage (read/write)
144
- - `altitude_feet` - Altitude in feet (read/write)
145
-
146
- #### `TrajectorySolver`
147
-
148
- Trajectory calculation engine.
149
-
150
- **Constructor:**
151
- ```ruby
152
- TrajectorySolver.new(
153
- inputs, # BallisticInputs object
154
- wind = nil, # WindConditions object (optional)
155
- atmosphere = nil # AtmosphericConditions object (optional)
156
- )
157
- ```
158
-
159
- **Methods:**
160
- - `solve()` - Calculate trajectory, returns `TrajectoryResult`
161
-
162
- #### `TrajectoryResult`
163
-
164
- Trajectory calculation results.
165
-
166
- **Attributes:**
167
- - `max_range_yards` - Maximum range in yards
168
- - `max_height_yards` - Maximum height in yards
169
- - `time_of_flight` - Time of flight in seconds
170
- - `impact_velocity_fps` - Impact velocity in fps
171
- - `impact_energy_ftlbs` - Impact energy in ft-lbs
172
- - `points` - Array of `TrajectoryPoint` objects
173
-
174
- #### `TrajectoryPoint`
175
-
176
- Individual point along trajectory.
177
-
178
- **Attributes:**
179
- - `time` - Time in seconds
180
- - `x` - Downrange distance in yards
181
- - `y` - Vertical position in yards (relative to line of sight)
182
- - `z` - Lateral drift in yards
183
- - `velocity_fps` - Velocity in fps
184
- - `energy_ftlbs` - Energy in ft-lbs
185
-
186
- #### `DragModel`
187
-
188
- Ballistic coefficient drag model.
189
-
190
- **Class Methods:**
191
- - `DragModel.g1()` - G1 drag model
192
- - `DragModel.g7()` - G7 drag model
193
- - `DragModel.g8()` - G8 drag model
107
+ ## API Reference
194
108
 
109
+ All functions live on the `BallisticsEngine` module and take a single hash with **string
110
+ keys** in imperial units. Unknown keys are ignored; only the seven marked required are needed.
111
+
112
+ ### `BallisticsEngine.solve(hash) => hash`
113
+
114
+ **Required:** `bc`, `bullet_weight_grains`, `muzzle_velocity_fps`, `bullet_diameter_inches`,
115
+ `bullet_length_inches`, `sight_height_inches`, `zero_distance_yards`.
116
+
117
+ **Optional (selected):**
118
+ - Aim/geometry: `drag_model`("G7"), `shooting_angle_degrees`, `muzzle_angle_degrees`,
119
+ `twist_rate_inches`(10), `is_right_twist`(true), `muzzle_height_inches`, `target_height_inches`.
120
+ - Wind: `wind` = `{speed_mph, direction_degrees}`; `wind_segments` = `[[speed_mph, angle_deg, until_yards], ...]`.
121
+ - Atmosphere: `atmosphere` = `{temperature_f, pressure_inhg, humidity_percent, altitude_feet}`.
122
+ - BC: `use_bc_segments`, `bc_segments_data` (see above), `bc_segments` = `[[mach, bc], ...]`.
123
+ - Coriolis: `enable_coriolis`, `latitude_degrees`, `shot_direction_degrees`.
124
+ - Physics flags: `enable_advanced_effects`, `enable_magnus`, `enable_aerodynamic_jump`,
125
+ `use_enhanced_spin_drift`, `use_form_factor`, `use_cluster_bc`, `enable_pitch_damping`,
126
+ `enable_precession_nutation`, `enable_wind_shear` (+ `wind_shear_model`), `use_rk4`, `use_adaptive_rk45`.
127
+ - Powder: `use_powder_sensitivity`, `powder_temp_sensitivity`, `powder_temp_f`,
128
+ `powder_temp_curve` = `[[temp_f, velocity_fps], ...]`, `powder_curve_temp_f`.
129
+ - Sampling/solver: `enable_trajectory_sampling`, `sample_interval_yards`, `max_range_yards`, `time_step_seconds`.
130
+
131
+ **Returns:** `max_range_yards`, `max_height_yards`, `time_of_flight`, `impact_velocity_fps`,
132
+ `impact_energy_ftlbs`, `points` (array of `{time, x, y, z, velocity_fps, energy_ftlbs}` — x=range,
133
+ y=drop, z=windage, in yards). Present when enabled: `sampled_points`, `aerodynamic_jump`,
134
+ `angular_state`, `max_yaw_angle_rad`, `max_precession_angle_rad`, `min_pitch_damping`, `transonic_mach`.
135
+
136
+ ### `BallisticsEngine.calculate_zero_angle(hash) => hash`
137
+
138
+ Same bullet keys as `solve`, plus **required** `target_distance_yards` (and optional
139
+ `target_height_inches`, `wind`, `atmosphere`). Returns `zero_angle_radians`,
140
+ `zero_angle_degrees`, `zero_angle_moa`. Raises on non-convergence.
141
+
142
+ ### `BallisticsEngine.monte_carlo(hash) => hash`
143
+
144
+ Bullet keys (optionally nested under `base_inputs`) plus std-dev params:
145
+ `num_simulations`(1000), `velocity_std_dev_fps`, `angle_std_dev_radians`, `bc_std_dev`,
146
+ `wind_speed_std_dev_mph`, `azimuth_std_dev_radians`, `target_distance_yards`,
147
+ `base_wind_speed_mph`, `base_wind_direction_degrees`, `hit_radius_inches`(≈11.8).
148
+ Returns `ranges_yards`, `impact_velocities_fps`, `impact_positions`
149
+ (`[{vertical_inches, lateral_inches}, ...]`), `hit_probability`, `num_simulations`.
195
150
  ## Development
196
151
 
197
152
  After checking out the repo, run `bundle install` to install dependencies.
@@ -4,5 +4,7 @@ require "ballistics_engine_rb"
4
4
 
5
5
  # Top-level module for the BallisticsEngine gem
6
6
  module BallisticsEngine
7
+ VERSION = "0.22.11"
8
+
7
9
  class Error < StandardError; end
8
10
  end
data/src/lib.rs CHANGED
@@ -1,6 +1,7 @@
1
- use magnus::{Error, RHash, function};
1
+ use magnus::{Error, RArray, RHash, function};
2
2
  use ballistics_engine::{
3
- DragModel, BallisticInputs, WindConditions, AtmosphericConditions, TrajectorySolver,
3
+ AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel, MonteCarloParams,
4
+ TrajectorySolver, WindConditions, calculate_zero_angle_with_conditions, run_monte_carlo,
4
5
  };
5
6
 
6
7
  // Unit conversion constants
@@ -9,149 +10,410 @@ const FPS_TO_MPS: f64 = 0.3048;
9
10
  const YARDS_TO_METERS: f64 = 0.9144;
10
11
  const INCHES_TO_METERS: f64 = 0.0254;
11
12
  const MPH_TO_MPS: f64 = 0.44704;
13
+ const MPH_TO_KMH: f64 = 1.609344;
14
+ const INHG_TO_HPA: f64 = 33.8639;
12
15
  const DEGREES_TO_RADIANS: f64 = std::f64::consts::PI / 180.0;
16
+ const JOULES_TO_FTLBS: f64 = 0.737562;
17
+ const METERS_TO_INCHES: f64 = 39.37007874;
13
18
 
14
- /// Calculate trajectory from Ruby hash input
15
- fn solve_trajectory(ruby: &magnus::Ruby, inputs_hash: RHash) -> Result<RHash, Error> {
16
- // Extract required values from Ruby hash
17
- let bc: f64 = inputs_hash.fetch("bc")?;
18
- let bullet_weight_grains: f64 = inputs_hash.fetch("bullet_weight_grains")?;
19
- let muzzle_velocity_fps: f64 = inputs_hash.fetch("muzzle_velocity_fps")?;
20
- let bullet_diameter_inches: f64 = inputs_hash.fetch("bullet_diameter_inches")?;
21
- let bullet_length_inches: f64 = inputs_hash.fetch("bullet_length_inches")?;
22
- let sight_height_inches: f64 = inputs_hash.fetch("sight_height_inches")?;
23
- let zero_distance_yards: f64 = inputs_hash.fetch("zero_distance_yards")?;
24
-
25
- // Optional values with defaults
26
- let shooting_angle_degrees: f64 = inputs_hash.lookup2("shooting_angle_degrees", 0.0)?;
27
- let twist_rate_inches: f64 = inputs_hash.lookup2("twist_rate_inches", 10.0)?;
28
- let is_right_twist: bool = inputs_hash.lookup2("is_right_twist", true)?;
29
- let enable_aerodynamic_jump: bool = inputs_hash.lookup2("enable_aerodynamic_jump", false)?;
30
-
31
- // Coriolis (Earth-rotation) inputs. Requires latitude_degrees to take effect; with
32
- // enable_coriolis + a latitude, shot_direction_degrees (compass bearing, 0=N, 90=E)
33
- // drives the lateral drift and the vertical Eotvos term (east lifts, west depresses).
34
- let enable_coriolis: bool = inputs_hash.lookup2("enable_coriolis", false)?;
35
- let latitude_degrees: Option<f64> = inputs_hash.lookup::<_, Option<f64>>("latitude_degrees")?;
36
- let shot_direction_degrees: f64 = inputs_hash.lookup2("shot_direction_degrees", 0.0)?;
37
-
38
- // Drag model (default to G7)
39
- let drag_model_str: String = inputs_hash.lookup2("drag_model", "G7")?;
40
- let drag_model = match drag_model_str.to_uppercase().as_str() {
41
- "G1" => DragModel::G1,
42
- "G7" => DragModel::G7,
43
- "G8" => DragModel::G8,
44
- _ => return Err(Error::new(ruby.exception_arg_error(), "Invalid drag_model, must be G1, G7, or G8")),
45
- };
46
-
47
- // Create ballistic inputs using defaults and override specific fields
48
- let ballistic_inputs = BallisticInputs {
49
- bc_type: drag_model,
50
- bc_value: bc,
51
- bullet_diameter: bullet_diameter_inches * INCHES_TO_METERS,
52
- bullet_mass: bullet_weight_grains * GRAINS_TO_KG,
53
- bullet_length: bullet_length_inches * INCHES_TO_METERS,
54
- muzzle_velocity: muzzle_velocity_fps * FPS_TO_MPS,
55
- sight_height: sight_height_inches * INCHES_TO_METERS,
56
- target_distance: zero_distance_yards * YARDS_TO_METERS,
57
- shooting_angle: shooting_angle_degrees * DEGREES_TO_RADIANS,
58
- twist_rate: twist_rate_inches, // Already in inches per the struct definition
59
- is_twist_right: is_right_twist,
60
- caliber_inches: bullet_diameter_inches,
61
- weight_grains: bullet_weight_grains,
62
- enable_aerodynamic_jump,
63
- enable_coriolis,
64
- latitude: latitude_degrees,
65
- shot_azimuth: shot_direction_degrees * DEGREES_TO_RADIANS,
66
- ..Default::default()
67
- };
19
+ // Typed wrappers around RHash::lookup2 magnus's lookup2<T,U,V> can't infer the output
20
+ // type V from the default U, so give each call site a concrete return type here.
21
+ fn opt_f64(h: &RHash, key: &str, default: f64) -> Result<f64, Error> {
22
+ h.lookup2(key, default)
23
+ }
24
+ fn opt_bool(h: &RHash, key: &str, default: bool) -> Result<bool, Error> {
25
+ h.lookup2(key, default)
26
+ }
27
+ fn opt_str(h: &RHash, key: &str, default: &str) -> Result<String, Error> {
28
+ h.lookup2(key, default.to_string())
29
+ }
30
+ fn opt_usize(h: &RHash, key: &str, default: usize) -> Result<usize, Error> {
31
+ h.lookup2(key, default)
32
+ }
68
33
 
69
- // Optional wind conditions (default to no wind)
70
- let wind = if let Some(wind_hash) = inputs_hash.lookup::<_, Option<RHash>>("wind")? {
71
- let speed_mph: f64 = wind_hash.lookup2("speed_mph", 0.0)?;
72
- let direction_deg: f64 = wind_hash.lookup2("direction_degrees", 0.0)?;
34
+ fn drag_from_str(ruby: &magnus::Ruby, s: &str) -> Result<DragModel, Error> {
35
+ match s.to_uppercase().as_str() {
36
+ "G1" => Ok(DragModel::G1),
37
+ "G7" => Ok(DragModel::G7),
38
+ "G8" => Ok(DragModel::G8),
39
+ _ => Err(Error::new(
40
+ ruby.exception_arg_error(),
41
+ "Invalid drag_model, must be G1, G7, or G8",
42
+ )),
43
+ }
44
+ }
73
45
 
74
- WindConditions {
75
- speed: speed_mph * MPH_TO_MPS,
76
- direction: direction_deg * DEGREES_TO_RADIANS,
77
- }
46
+ /// Build WindConditions from an optional `wind` sub-hash (mph & degrees -> m/s & radians).
47
+ fn build_wind(h: &RHash) -> Result<WindConditions, Error> {
48
+ if let Some(w) = h.lookup::<_, Option<RHash>>("wind")? {
49
+ Ok(WindConditions {
50
+ speed: opt_f64(&w, "speed_mph", 0.0)? * MPH_TO_MPS,
51
+ direction: opt_f64(&w, "direction_degrees", 0.0)? * DEGREES_TO_RADIANS,
52
+ })
78
53
  } else {
79
- WindConditions {
54
+ Ok(WindConditions {
80
55
  speed: 0.0,
81
56
  direction: 0.0,
82
- }
83
- };
57
+ })
58
+ }
59
+ }
84
60
 
85
- // Optional atmospheric conditions (default to standard conditions)
86
- let atmosphere = if let Some(atm_hash) = inputs_hash.lookup::<_, Option<RHash>>("atmosphere")? {
87
- let temp_f: f64 = atm_hash.lookup2("temperature_f", 59.0)?;
88
- let pressure_inhg: f64 = atm_hash.lookup2("pressure_inhg", 29.92)?;
89
- let humidity: f64 = atm_hash.lookup2("humidity_percent", 50.0)?;
90
- let altitude_ft: f64 = atm_hash.lookup2("altitude_feet", 0.0)?;
91
-
92
- // Convert to the units AtmosphericConditions expects.
93
- let temp_c = (temp_f - 32.0) * 5.0 / 9.0;
94
- // pressure is hectopascals (hPa/millibars), NOT pascals. 1 inHg = 33.8639 hPa.
95
- // (Passing Pa here over-states air density ~100x and crushes the trajectory.)
96
- let pressure_hpa = pressure_inhg * 33.8639;
97
- let altitude_m = altitude_ft * 0.3048;
98
-
99
- AtmosphericConditions {
100
- temperature: temp_c,
101
- pressure: pressure_hpa,
102
- humidity,
103
- altitude: altitude_m,
104
- }
61
+ /// Build AtmosphericConditions from an optional `atmosphere` sub-hash. Note:
62
+ /// AtmosphericConditions.humidity is PERCENT (0-100), pressure is hPa, temp is Celsius.
63
+ fn build_atmosphere(h: &RHash) -> Result<AtmosphericConditions, Error> {
64
+ if let Some(a) = h.lookup::<_, Option<RHash>>("atmosphere")? {
65
+ let temp_f = opt_f64(&a, "temperature_f", 59.0)?;
66
+ let pressure_inhg = opt_f64(&a, "pressure_inhg", 29.92)?;
67
+ Ok(AtmosphericConditions {
68
+ temperature: (temp_f - 32.0) * 5.0 / 9.0,
69
+ pressure: pressure_inhg * INHG_TO_HPA,
70
+ humidity: opt_f64(&a, "humidity_percent", 50.0)?,
71
+ altitude: opt_f64(&a, "altitude_feet", 0.0)? * FPS_TO_MPS,
72
+ })
105
73
  } else {
106
- // Standard ICAO atmosphere
107
- AtmosphericConditions {
108
- temperature: 15.0, // 15°C (59°F)
109
- pressure: 1013.25, // 1 atm in hPa (NOT Pa)
74
+ Ok(AtmosphericConditions {
75
+ temperature: 15.0,
76
+ pressure: 1013.25,
110
77
  humidity: 50.0,
111
78
  altitude: 0.0,
79
+ })
80
+ }
81
+ }
82
+
83
+ /// `bc_segments` = array of [mach, bc] pairs (Mach-keyed). Converts directly.
84
+ fn extract_bc_segments(h: &RHash) -> Result<Option<Vec<(f64, f64)>>, Error> {
85
+ match h.lookup::<_, Option<Vec<(f64, f64)>>>("bc_segments")? {
86
+ Some(v) if !v.is_empty() => Ok(Some(v)),
87
+ _ => Ok(None),
88
+ }
89
+ }
90
+
91
+ /// `bc_segments_data` = array of {velocity_min_fps, velocity_max_fps, bc} hashes
92
+ /// OR [vmin_fps, vmax_fps, bc] triples. Velocities stay in FPS: the solver compares
93
+ /// BCSegmentData bounds directly against the bullet's velocity_fps (not m/s).
94
+ /// RHash is not TryConvertOwned, so iterate by index and try the hash form first.
95
+ fn extract_bc_segments_data(h: &RHash) -> Result<Vec<BCSegmentData>, Error> {
96
+ let mut out = Vec::new();
97
+ if let Some(arr) = h.lookup::<_, Option<RArray>>("bc_segments_data")? {
98
+ for i in 0..arr.len() {
99
+ if let Ok(seg) = arr.entry::<RHash>(i as isize) {
100
+ let bc = match seg.lookup::<_, Option<f64>>("bc")? {
101
+ Some(v) => v,
102
+ None => opt_f64(&seg, "bc_value", 0.0)?,
103
+ };
104
+ out.push(BCSegmentData {
105
+ velocity_min: seg.fetch::<_, f64>("velocity_min_fps")?,
106
+ velocity_max: seg.fetch::<_, f64>("velocity_max_fps")?,
107
+ bc_value: bc,
108
+ });
109
+ } else {
110
+ let (vmin, vmax, bc): (f64, f64, f64) = arr.entry(i as isize)?;
111
+ out.push(BCSegmentData {
112
+ velocity_min: vmin,
113
+ velocity_max: vmax,
114
+ bc_value: bc,
115
+ });
116
+ }
112
117
  }
118
+ }
119
+ Ok(out)
120
+ }
121
+
122
+ /// `wind_segments` = array of [speed_mph, angle_degrees, until_yards] -> (km/h, deg, meters).
123
+ fn extract_wind_segments(h: &RHash) -> Result<Vec<(f64, f64, f64)>, Error> {
124
+ match h.lookup::<_, Option<Vec<(f64, f64, f64)>>>("wind_segments")? {
125
+ Some(v) => Ok(v
126
+ .into_iter()
127
+ .map(|(mph, ang, until_yd)| (mph * MPH_TO_KMH, ang, until_yd * YARDS_TO_METERS))
128
+ .collect()),
129
+ None => Ok(Vec::new()),
130
+ }
131
+ }
132
+
133
+ /// `powder_temp_curve` = array of [temp_f, velocity_fps] -> [(celsius, m/s)].
134
+ fn extract_powder_curve(h: &RHash) -> Result<Option<Vec<(f64, f64)>>, Error> {
135
+ match h.lookup::<_, Option<Vec<(f64, f64)>>>("powder_temp_curve")? {
136
+ Some(v) if !v.is_empty() => Ok(Some(
137
+ v.into_iter()
138
+ .map(|(tf, vfps)| ((tf - 32.0) * 5.0 / 9.0, vfps * FPS_TO_MPS))
139
+ .collect(),
140
+ )),
141
+ _ => Ok(None),
142
+ }
143
+ }
144
+
145
+ /// Build a fully-populated BallisticInputs from an imperial Ruby hash (imperial -> SI).
146
+ /// Shared by solve, calculate_zero_angle, and monte_carlo.
147
+ fn build_inputs(ruby: &magnus::Ruby, h: &RHash) -> Result<BallisticInputs, Error> {
148
+ let drag_model = drag_from_str(ruby, &opt_str(h, "drag_model", "G7")?)?;
149
+ let bullet_diameter_inches: f64 = h.fetch("bullet_diameter_inches")?;
150
+ let bullet_weight_grains: f64 = h.fetch("bullet_weight_grains")?;
151
+
152
+ let bc_seg_data = extract_bc_segments_data(h)?;
153
+
154
+ let mut inputs = BallisticInputs {
155
+ bc_type: drag_model,
156
+ bc_value: h.fetch("bc")?,
157
+ bullet_diameter: bullet_diameter_inches * INCHES_TO_METERS,
158
+ bullet_mass: bullet_weight_grains * GRAINS_TO_KG,
159
+ bullet_length: h.fetch::<_, f64>("bullet_length_inches")? * INCHES_TO_METERS,
160
+ muzzle_velocity: h.fetch::<_, f64>("muzzle_velocity_fps")? * FPS_TO_MPS,
161
+ sight_height: h.fetch::<_, f64>("sight_height_inches")? * INCHES_TO_METERS,
162
+ target_distance: h.fetch::<_, f64>("zero_distance_yards")? * YARDS_TO_METERS,
163
+ // geometry / aim
164
+ shooting_angle: opt_f64(h, "shooting_angle_degrees", 0.0)? * DEGREES_TO_RADIANS,
165
+ muzzle_angle: opt_f64(h, "muzzle_angle_degrees", 0.0)? * DEGREES_TO_RADIANS,
166
+ azimuth_angle: opt_f64(h, "azimuth_angle_degrees", 0.0)? * DEGREES_TO_RADIANS,
167
+ twist_rate: opt_f64(h, "twist_rate_inches", 10.0)?, // inches/turn, NOT SI
168
+ is_twist_right: opt_bool(h, "is_right_twist", true)?,
169
+ caliber_inches: bullet_diameter_inches,
170
+ weight_grains: bullet_weight_grains,
171
+ muzzle_height: opt_f64(h, "muzzle_height_inches", 0.0)? * INCHES_TO_METERS,
172
+ target_height: opt_f64(h, "target_height_inches", 0.0)? * INCHES_TO_METERS,
173
+ // physics flags
174
+ enable_aerodynamic_jump: opt_bool(h, "enable_aerodynamic_jump", false)?,
175
+ enable_advanced_effects: opt_bool(h, "enable_advanced_effects", false)?,
176
+ enable_magnus: opt_bool(h, "enable_magnus", false)?,
177
+ enable_coriolis: opt_bool(h, "enable_coriolis", false)?,
178
+ latitude: h.lookup::<_, Option<f64>>("latitude_degrees")?,
179
+ shot_azimuth: opt_f64(h, "shot_direction_degrees", 0.0)? * DEGREES_TO_RADIANS,
180
+ use_enhanced_spin_drift: opt_bool(h, "use_enhanced_spin_drift", false)?,
181
+ use_form_factor: opt_bool(h, "use_form_factor", false)?,
182
+ use_cluster_bc: opt_bool(h, "use_cluster_bc", false)?,
183
+ enable_pitch_damping: opt_bool(h, "enable_pitch_damping", false)?,
184
+ enable_precession_nutation: opt_bool(h, "enable_precession_nutation", false)?,
185
+ use_rk4: opt_bool(h, "use_rk4", true)?,
186
+ use_adaptive_rk45: opt_bool(h, "use_adaptive_rk45", true)?,
187
+ tipoff_yaw: opt_f64(h, "tipoff_yaw_degrees", 0.0)? * DEGREES_TO_RADIANS,
188
+ tipoff_decay_distance: opt_f64(h, "tipoff_decay_distance_yards", 50.0)? * YARDS_TO_METERS,
189
+ // powder sensitivity
190
+ use_powder_sensitivity: opt_bool(h, "use_powder_sensitivity", false)?,
191
+ powder_temp_sensitivity: opt_f64(h, "powder_temp_sensitivity", 0.0)?, // (m/s)/C
192
+ powder_temp: (opt_f64(h, "powder_temp_f", 59.0)? - 32.0) * 5.0 / 9.0,
193
+ powder_temp_curve: extract_powder_curve(h)?,
194
+ powder_curve_temp_c: h
195
+ .lookup::<_, Option<f64>>("powder_curve_temp_f")?
196
+ .map(|tf| (tf - 32.0) * 5.0 / 9.0),
197
+ // wind shear
198
+ enable_wind_shear: opt_bool(h, "enable_wind_shear", false)?,
199
+ wind_shear_model: opt_str(h, "wind_shear_model", "none")?,
200
+ // sampling
201
+ enable_trajectory_sampling: opt_bool(h, "enable_trajectory_sampling", false)?,
202
+ // BC segments
203
+ use_bc_segments: opt_bool(h, "use_bc_segments", false)?,
204
+ bc_segments: extract_bc_segments(h)?,
205
+ bc_segments_data: if bc_seg_data.is_empty() {
206
+ None
207
+ } else {
208
+ Some(bc_seg_data)
209
+ },
210
+ // metadata
211
+ manufacturer: h.lookup::<_, Option<String>>("manufacturer")?,
212
+ bullet_model: h.lookup::<_, Option<String>>("bullet_model")?,
213
+ bullet_id: h.lookup::<_, Option<String>>("bullet_id")?,
214
+ bullet_cluster: h.lookup::<_, Option<usize>>("bullet_cluster")?,
215
+ ..Default::default()
113
216
  };
114
217
 
115
- // Solve trajectory - handle Result properly
116
- let solver = TrajectorySolver::new(ballistic_inputs, wind, atmosphere);
117
- let result = solver.solve()
118
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
218
+ // ground_threshold / sample_interval have non-trivial engine defaults (-100 m / 10 m);
219
+ // only override them if the caller supplied a value (in yards -> meters).
220
+ if let Some(gt) = h.lookup::<_, Option<f64>>("ground_threshold_yards")? {
221
+ inputs.ground_threshold = gt * YARDS_TO_METERS;
222
+ }
223
+ if let Some(si) = h.lookup::<_, Option<f64>>("sample_interval_yards")? {
224
+ inputs.sample_interval = si * YARDS_TO_METERS;
225
+ }
119
226
 
120
- // Convert result to Ruby hash using Ruby context methods
121
- let result_hash = ruby.hash_new();
227
+ Ok(inputs)
228
+ }
122
229
 
123
- result_hash.aset("max_range_yards", result.max_range / YARDS_TO_METERS)?;
124
- result_hash.aset("max_height_yards", result.max_height / YARDS_TO_METERS)?;
125
- result_hash.aset("time_of_flight", result.time_of_flight)?;
126
- result_hash.aset("impact_velocity_fps", result.impact_velocity / FPS_TO_MPS)?;
127
- result_hash.aset("impact_energy_ftlbs", result.impact_energy * 0.737562)?; // J to ft-lbs
230
+ /// Calculate a trajectory from an imperial Ruby hash. Returns a Ruby hash.
231
+ fn solve_trajectory(ruby: &magnus::Ruby, h: RHash) -> Result<RHash, Error> {
232
+ let inputs = build_inputs(ruby, &h)?;
233
+ let wind = build_wind(&h)?;
234
+ let atmosphere = build_atmosphere(&h)?;
128
235
 
129
- // Convert trajectory points to array of hashes - use correct field name "points"
130
- let points = ruby.ary_new();
131
- for point in result.points {
132
- let point_hash = ruby.hash_new();
133
- point_hash.aset("time", point.time)?;
134
- point_hash.aset("x", point.position.x / YARDS_TO_METERS)?;
135
- point_hash.aset("y", point.position.y / YARDS_TO_METERS)?;
136
- point_hash.aset("z", point.position.z / YARDS_TO_METERS)?;
236
+ let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
237
+ let segs = extract_wind_segments(&h)?;
238
+ if !segs.is_empty() {
239
+ solver.set_wind_segments(segs);
240
+ }
241
+ if let Some(mr) = h.lookup::<_, Option<f64>>("max_range_yards")? {
242
+ solver.set_max_range(mr * YARDS_TO_METERS);
243
+ }
244
+ if let Some(ts) = h.lookup::<_, Option<f64>>("time_step_seconds")? {
245
+ solver.set_time_step(ts);
246
+ }
137
247
 
138
- // Use the velocity_magnitude field directly
139
- point_hash.aset("velocity_fps", point.velocity_magnitude / FPS_TO_MPS)?;
248
+ let result = solver
249
+ .solve()
250
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
140
251
 
141
- // Use the kinetic_energy field directly
142
- point_hash.aset("energy_ftlbs", point.kinetic_energy * 0.737562)?;
252
+ let out = ruby.hash_new();
253
+ out.aset("max_range_yards", result.max_range / YARDS_TO_METERS)?;
254
+ out.aset("max_height_yards", result.max_height / YARDS_TO_METERS)?;
255
+ out.aset("time_of_flight", result.time_of_flight)?;
256
+ out.aset("impact_velocity_fps", result.impact_velocity / FPS_TO_MPS)?;
257
+ out.aset("impact_energy_ftlbs", result.impact_energy * JOULES_TO_FTLBS)?;
143
258
 
144
- points.push(point_hash)?;
259
+ let points = ruby.ary_new();
260
+ for point in &result.points {
261
+ let ph = ruby.hash_new();
262
+ ph.aset("time", point.time)?;
263
+ ph.aset("x", point.position.x / YARDS_TO_METERS)?;
264
+ ph.aset("y", point.position.y / YARDS_TO_METERS)?;
265
+ ph.aset("z", point.position.z / YARDS_TO_METERS)?;
266
+ ph.aset("velocity_fps", point.velocity_magnitude / FPS_TO_MPS)?;
267
+ ph.aset("energy_ftlbs", point.kinetic_energy * JOULES_TO_FTLBS)?;
268
+ points.push(ph)?;
145
269
  }
270
+ out.aset("points", points)?;
146
271
 
147
- result_hash.aset("points", points)?;
272
+ // Rich output (present only when the corresponding feature produced Some).
273
+ if let Some(samples) = &result.sampled_points {
274
+ let arr = ruby.ary_new();
275
+ for s in samples {
276
+ let sh = ruby.hash_new();
277
+ sh.aset("distance_yards", s.distance_m / YARDS_TO_METERS)?;
278
+ sh.aset("drop_inches", s.drop_m * METERS_TO_INCHES)?;
279
+ sh.aset("wind_drift_inches", s.wind_drift_m * METERS_TO_INCHES)?;
280
+ sh.aset("velocity_fps", s.velocity_mps / FPS_TO_MPS)?;
281
+ sh.aset("energy_ftlbs", s.energy_j * JOULES_TO_FTLBS)?;
282
+ sh.aset("time", s.time_s)?;
283
+ let flags = ruby.ary_new();
284
+ for f in &s.flags {
285
+ flags.push(format!("{:?}", f))?;
286
+ }
287
+ sh.aset("flags", flags)?;
288
+ arr.push(sh)?;
289
+ }
290
+ out.aset("sampled_points", arr)?;
291
+ }
292
+ if let Some(aj) = &result.aerodynamic_jump {
293
+ let jh = ruby.hash_new();
294
+ jh.aset("vertical_jump_moa", aj.vertical_jump_moa)?;
295
+ jh.aset("horizontal_jump_moa", aj.horizontal_jump_moa)?;
296
+ jh.aset("jump_angle_rad", aj.jump_angle_rad)?;
297
+ jh.aset("magnus_component_moa", aj.magnus_component_moa)?;
298
+ jh.aset("yaw_component_moa", aj.yaw_component_moa)?;
299
+ jh.aset("stabilization_factor", aj.stabilization_factor)?;
300
+ out.aset("aerodynamic_jump", jh)?;
301
+ }
302
+ if let Some(a) = &result.angular_state {
303
+ let ah = ruby.hash_new();
304
+ ah.aset("pitch_angle", a.pitch_angle)?;
305
+ ah.aset("yaw_angle", a.yaw_angle)?;
306
+ ah.aset("pitch_rate", a.pitch_rate)?;
307
+ ah.aset("yaw_rate", a.yaw_rate)?;
308
+ ah.aset("precession_angle", a.precession_angle)?;
309
+ ah.aset("nutation_phase", a.nutation_phase)?;
310
+ out.aset("angular_state", ah)?;
311
+ }
312
+ if let Some(v) = result.max_yaw_angle {
313
+ out.aset("max_yaw_angle_rad", v)?;
314
+ }
315
+ if let Some(v) = result.max_precession_angle {
316
+ out.aset("max_precession_angle_rad", v)?;
317
+ }
318
+ if let Some(v) = result.min_pitch_damping {
319
+ out.aset("min_pitch_damping", v)?;
320
+ }
321
+ if let Some(v) = result.transonic_mach {
322
+ out.aset("transonic_mach", v)?;
323
+ }
324
+
325
+ Ok(out)
326
+ }
327
+
328
+ /// Solve for the launch angle that zeroes at a target distance.
329
+ fn calculate_zero_angle(ruby: &magnus::Ruby, h: RHash) -> Result<RHash, Error> {
330
+ let inputs = build_inputs(ruby, &h)?;
331
+ let target_yd: f64 = h.fetch("target_distance_yards")?;
332
+ let target_h_in: f64 = h.lookup2("target_height_inches", 0.0)?;
333
+ let wind = build_wind(&h)?;
334
+ let atmosphere = build_atmosphere(&h)?;
335
+
336
+ let rad = calculate_zero_angle_with_conditions(
337
+ inputs,
338
+ target_yd * YARDS_TO_METERS,
339
+ target_h_in * INCHES_TO_METERS,
340
+ wind,
341
+ atmosphere,
342
+ )
343
+ .map_err(|e| {
344
+ Error::new(
345
+ ruby.exception_runtime_error(),
346
+ format!("Unable to find zero angle for target distance {target_yd}: {e}"),
347
+ )
348
+ })?;
148
349
 
149
- Ok(result_hash)
350
+ let out = ruby.hash_new();
351
+ out.aset("zero_angle_radians", rad)?;
352
+ out.aset("zero_angle_degrees", rad / DEGREES_TO_RADIANS)?;
353
+ out.aset("zero_angle_moa", rad / DEGREES_TO_RADIANS * 60.0)?;
354
+ Ok(out)
355
+ }
356
+
357
+ /// Monte Carlo dispersion. Reads `base_inputs` sub-hash (or the top-level hash) for the
358
+ /// bullet, plus std-dev params, and returns per-shot ranges/velocities/positions + hit prob.
359
+ fn monte_carlo(ruby: &magnus::Ruby, h: RHash) -> Result<RHash, Error> {
360
+ let base = match h.lookup::<_, Option<RHash>>("base_inputs")? {
361
+ Some(b) => b,
362
+ None => h,
363
+ };
364
+ let inputs = build_inputs(ruby, &base)?;
365
+
366
+ let params = MonteCarloParams {
367
+ num_simulations: opt_usize(&h, "num_simulations", 1000)?,
368
+ velocity_std_dev: opt_f64(&h, "velocity_std_dev_fps", 0.0)? * FPS_TO_MPS,
369
+ angle_std_dev: opt_f64(&h, "angle_std_dev_radians", 0.001)?,
370
+ bc_std_dev: opt_f64(&h, "bc_std_dev", 0.01)?,
371
+ wind_speed_std_dev: opt_f64(&h, "wind_speed_std_dev_mph", 0.0)? * MPH_TO_MPS,
372
+ target_distance: h
373
+ .lookup::<_, Option<f64>>("target_distance_yards")?
374
+ .map(|y| y * YARDS_TO_METERS),
375
+ base_wind_speed: opt_f64(&h, "base_wind_speed_mph", 0.0)? * MPH_TO_MPS,
376
+ base_wind_direction: opt_f64(&h, "base_wind_direction_degrees", 0.0)? * DEGREES_TO_RADIANS,
377
+ azimuth_std_dev: opt_f64(&h, "azimuth_std_dev_radians", 0.001)?,
378
+ };
379
+
380
+ let res = run_monte_carlo(inputs, params)
381
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
382
+
383
+ let out = ruby.hash_new();
384
+ out.aset(
385
+ "ranges_yards",
386
+ res.ranges
387
+ .iter()
388
+ .map(|m| m / YARDS_TO_METERS)
389
+ .collect::<Vec<f64>>(),
390
+ )?;
391
+ out.aset(
392
+ "impact_velocities_fps",
393
+ res.impact_velocities
394
+ .iter()
395
+ .map(|v| v / FPS_TO_MPS)
396
+ .collect::<Vec<f64>>(),
397
+ )?;
398
+ let positions = ruby.ary_new();
399
+ for p in &res.impact_positions {
400
+ let ph = ruby.hash_new();
401
+ ph.aset("vertical_inches", p.y * METERS_TO_INCHES)?; // McCoy Y = vertical
402
+ ph.aset("lateral_inches", p.z * METERS_TO_INCHES)?; // McCoy Z = lateral
403
+ positions.push(ph)?;
404
+ }
405
+ out.aset("impact_positions", positions)?;
406
+ let radius_m = opt_f64(&h, "hit_radius_inches", 0.3 * METERS_TO_INCHES)? / METERS_TO_INCHES;
407
+ out.aset("hit_probability", res.hit_probability(radius_m))?;
408
+ out.aset("num_simulations", res.ranges.len())?;
409
+ Ok(out)
150
410
  }
151
411
 
152
412
  #[magnus::init]
153
413
  fn init(ruby: &magnus::Ruby) -> Result<(), Error> {
154
414
  let module = ruby.define_module("BallisticsEngine")?;
155
415
  module.define_module_function("solve", function!(solve_trajectory, 1))?;
416
+ module.define_module_function("calculate_zero_angle", function!(calculate_zero_angle, 1))?;
417
+ module.define_module_function("monte_carlo", function!(monte_carlo, 1))?;
156
418
  Ok(())
157
419
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ballistics-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.21.5
4
+ version: 0.22.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Jokela
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-27 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys