ballistics-engine 0.28.1

High-performance ballistics trajectory engine with professional physics
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
# Ballistics Engine CLI Tool

Comprehensive command-line interface for professional ballistics trajectory calculations with advanced drag modeling and automatic zeroing.

## Installation

```bash
# Build from source
cargo build --release

# Binary location
./target/release/ballistics
```

## Unit Systems

The CLI supports two unit systems, selectable with the `--units` flag (default: Imperial)

### Imperial Units (Default)
- Velocity: feet per second (fps)
- Mass: grains
- Distance: yards
- Diameter: inches
- Sight Height: inches
- Bore Height: feet
- Temperature: Fahrenheit
- Pressure: inHg

### Metric Units
- Velocity: meters per second (m/s)
- Mass: grams
- Distance: meters
- Diameter: millimeters (mm)
- Sight Height: millimeters (mm)
- Bore Height: meters
- Temperature: Celsius
- Pressure: hPa

## Turret Adjustment Units

Separate from `--units` (imperial/metric, above), most sweep-table commands take an
`--adjustment-unit` flag selecting how angular dial/hold columns (Drop, Wind, Lead, the
mover Ring) are displayed. Five values are accepted (MBA-724, MBA-1355):

| Value | Meaning | Conversion |
|---|---|---|
| `mil` (default) | Milliradians | `(drop_yd / range_yd) × 1000` |
| `moa` | True Minutes of Angle | `(drop_yd / range_yd) × 3438` — the CLI's locked printed-table dial constant, deliberately not the geometrically exact 3437.7467 (MBA-724) |
| `smoa` | Shooter's MOA (exactly 1 inch per 100 yards) | `(drop_yd / range_yd) × 3600` |
| `iphy` | Inches per hundred yards | numerically identical to `smoa` — same conversion, different header text |
| `clicks` | Whole turret clicks | see below — requires a click graduation, not a fixed factor |

### `clicks`: whole-click output

`--adjustment-unit clicks` rounds the angular adjustment to the nearest **whole turret
click** instead of printing an angle — ties round away from zero, sign is preserved, and
ranges under 1 yard/meter are defined as zero adjustment (same short-range guard as
every other adjustment unit). It needs a **click graduation** — the angular size of one
click on your turret — which has no default and must come from one of two places:

- **`--elevation-click-value <SIZE><UNIT>`** / **`--windage-click-value <SIZE><UNIT>`** —
  CLI flags, e.g. `--elevation-click-value 0.25moa` or `--elevation-click-value 0.1mil`.
  The suffix is mandatory and selects the graduation's own base unit — `mil`, `moa`,
  `smoa`, or `iphy` (`iphy` is accepted as an alias for `smoa`, the identical unit); the
  magnitude must be a positive, finite number.
- **A saved profile's `elevation_click` / `windage_click` fields** — see [Importing
  profiles (.a7p)](#importing-profiles-a7p) below for saved-profile basics; set the click
  fields with `profile save --elevation-click <SIZE><UNIT> --windage-click <SIZE><UNIT>`,
  validated with the same parser at save time so a profile can never store an invalid
  graduation.

**Resolution order** (checked once, eagerly, before any calculation): an explicit CLI
flag beats the saved profile's field for that axis. **Windage falls back to elevation**
when neither `--windage-click-value` nor the profile's `windage_click` is set — most
turrets share one graduation between the two knobs. **Elevation must resolve from at
least one source** — clicks output has nowhere else to get a graduation from — so a run
with neither an elevation flag nor a profile elevation click fails fast:

```
error: --adjustment-unit clicks requires a turret elevation graduation: pass --elevation-click-value <SIZE><UNIT> (e.g. 0.25moa or 0.1mil), or save one on the profile with `profile save --elevation-click`
```

**Scope: `trajectory` and `come-ups` only.** Every other command that still accepts
`--adjustment-unit` (`lead`, `wind-card`, `range-table`, `compare`) rejects `clicks`
immediately, rather than silently falling back to another unit:

```
error: --adjustment-unit clicks is currently supported for trajectory and come-ups only (MBA-1355)
```

Where clicks resolves, the header/column suffix follows the same `(mil)`/`(moa)`
convention as every other unit — e.g. the mover Ring column reads `Ring(clicks)`, and
come-ups' Drop column reads `Drop (CLICKS)` — and the values print as whole integers
instead of a decimal angle. Drop/Ring use the **elevation** click graduation; Wind/Lead
(on the PDF dope card) use the **windage** one. `come-ups` has no windage column, so its
`--windage-click-value` flag exists only for CLI parity and validation, and does not
affect its output. The default `mil`/`moa`/`smoa`/`iphy` output is completely unaffected
by any of this — `clicks` is strictly additive.

## Commands

### Trajectory Calculation

Calculate ballistic trajectories with advanced physics modeling:

```bash
# Basic trajectory (Imperial - default)
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308

# With automatic zeroing at 200 yards
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --auto-zero 200

# Metric units
./ballistics trajectory --units metric -v 823 -b 0.475 -m 10.9 -d 7.82

# Full example with environmental conditions
./ballistics trajectory \
  -v 2700          # Velocity (fps)
  -b 0.475         # Ballistic coefficient
  -m 168           # Mass (grains)
  -d 0.308         # Diameter (inches)
  --drag-model g7  # G7 drag model
  --auto-zero 200  # Zero at 200 yards
  --max-range 1000 # Max range (yards)
  --wind-speed 10  # Wind (mph)
  --wind-direction 90 # Wind from right
  --temperature 59 # Temp (°F)
  --pressure 29.92 # Pressure (inHg)
  --humidity 50    # Humidity (%)
  --altitude 5000  # Altitude (feet)
  --full          # Show all points
```

#### Advanced BC Options

```bash
# Enable velocity-based BC segmentation
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
  --use-bc-segments \
  --auto-zero 600
```

`--use-bc-segments`' automatic characteristic-based estimator (used when no explicit
`--bc-segment`/`--bc-table-dir` schedule is supplied) is **G1/G7 only** — a wider drag
model warns and is treated as G1 for the estimate, same as `true-velocity`/`plan-truing`.

### Custom Drag Tables

Supply a measured or manufacturer-published drag curve — Hornady CDM data, a Lapua/Doppler-radar-derived deck, or your own — instead of relying on a G1/G7 reference curve plus a single BC value. Available via `--drag-table <FILE>` on the `trajectory`, `zero`, and `monte-carlo` subcommands.

**CSV format:** two columns, `mach,cd`, one point per line.
- A single leading header row (e.g. `mach,cd`) is tolerated and skipped.
- Blank lines and lines starting with `#` are ignored.
- Mach must be strictly ascending, with at least 2 data points.
- Cd must be finite and greater than 0.
- **Mach-keyed only.** Velocity-keyed decks (e.g. raw Doppler output in fps/m/s) must be converted by you first: `mach = velocity / speed_of_sound` at the conditions the velocity was measured under.

**`.drg` format:** `--drag-table` also accepts a file with a `.drg` extension (case-insensitive), a small vendor text format used to distribute Doppler-radar-measured drag curves (e.g. Lapua's free QuickTARGET Unlimited downloads). It tolerates a leading name/description line, tab/comma/semicolon-separated fields, and either `(mach, cd)` or `(cd, mach)` column order (detected automatically); any error names the file, the `.drg` format, and the offending line number. Dispatch is purely by file extension — a `.drg`-suffixed file is never parsed as CSV, and every other extension (including plain `.csv` or none) always goes through the CSV format above, unchanged. The engine ships no vendor drag curves of its own and cannot download them for you (licensing): obtain a `.drg` or CSV file from your manufacturer/measurement source yourself and point `--drag-table` at the local copy.

**Worked example:**

```bash
cat > deck.csv <<'EOF'
mach,cd
0.5,0.220
0.8,0.230
1.0,0.520
1.2,0.480
1.5,0.400
2.0,0.330
2.5,0.300
EOF

./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --drag-table deck.csv --max-range 500
```

`-b`/`--bc` may still be supplied but its value is ignored once `--drag-table` is set. (On `trajectory` it is optional and defaults to 0.5; on `zero` and `monte-carlo` it remains a required argument, though it is likewise ignored for drag when a table is active.) — the deck supplies Cd directly. `-m/--mass` and `-d/--diameter` remain **required** (grains/inches under imperial, grams/mm under `--units metric`): the engine divides the deck's Cd by the projectile's sectional density (derived from mass and diameter) in place of the usual BC-based retardation denominator.

**Precedence:** a custom drag table completely replaces the G1/G7 model and any BC. It also takes precedence over `--use-bc-segments` / `--bc-segment`; if both are supplied, the drag table wins and a warning is printed:

```
Warning: --drag-table and BC segments were both provided; the drag table takes precedence and BC segments are ignored.
```

**Out-of-range policy:** Mach numbers outside the table's measured domain **hold the nearest tabulated Cd** rather than extrapolating. On `trajectory`, if the shot's Mach range (muzzle to impact) extends beyond the table's domain, a coverage warning is printed:

```
Warning: shot Mach range [1.47, 2.42] extends beyond the drag table domain [0.80, 1.20]; the nearest tabulated Cd is held outside that range (approximate).
```

**Monte Carlo caveat:** when a custom drag table is active, `--bc-std` dispersion is a no-op — the table fixes Cd directly, so perturbing the (ignored) BC value has no effect on drag. Velocity, angle, and wind dispersion still vary normally.

**WASM:** the browser/Node build has no filesystem, so `--drag-table <FILE>` isn't a CLI flag there. Instead, call `wasm.loadDragTable(bytes)` with the raw bytes of the same `mach,cd` CSV (parsed by the identical `DragTable::from_csv_str`) before running a command; `wasm.hasDragTable()` reports whether one is loaded. With no file extension available to dispatch on, `loadDragTable` tries CSV first exactly as before, and only on CSV failure — if the text looks like a `.drg` deck — retries it through the same `.drg` parser the native CLI uses; if neither format parses, the error names both. Once loaded, the table is applied automatically to every `trajectory`, `zero`, `lead`, and `monte-carlo` run — including `lead`, which has no native `--drag-table` flag of its own — until a new table replaces it. See `loadDragTable`'s doc comment in `src/wasm.rs` for the exact contract.

#### Whole-Curve Drag Scale (`--cd-scale`)

For after-the-fact truing of a custom deck against chronograph/observed data, `--cd-scale <FACTOR>` multiplies every interpolated Cd by a constant factor — `Cd_used = table.interpolate(mach) * cd_scale` — the same mechanism a Hornady 4DOF "AFF" (Aerodynamic Fudge Factor) or an Applied Ballistics "CDF" (Cd Factor) applies to true a whole measured curve. This is distinct from `--bc-adjustment`, which trues a scalar BC on the G1/G7 path; a custom drag table has no BC to adjust, so `--cd-scale` is its equivalent. `1.0` is neutral (byte-identical to omitting the flag); the typical truing range is `0.90`-`1.10`. Works identically with both the CSV and `.drg` deck formats — it scales the interpolated Cd after either loader, so the source format doesn't matter.

Available on `trajectory`, `zero`, and `monte-carlo` — the same trio as `--drag-table` — and **requires** it: supplying `--cd-scale` without `--drag-table` fails before any solve, naming `--bc-adjustment` as the G1/G7 alternative:

```
error: --cd-scale requires --drag-table (for G1/G7 use --bc-adjustment instead)
```

A value far outside the typical truing range (outside `[0.5, 2.0]`) is still accepted — the engine's own gate is only finite and `> 0` — but warns once on stderr:

```
warning: --cd-scale 3 is far outside the typical truing range (0.90-1.10)
```

**WASM:** pass `--cd-scale <FACTOR>` as a terminal argument to `trajectory`, `zero`, or `monte-carlo` alongside a table loaded via `loadDragTable`; the pairing requirement and range warning are identical (the pairing failure surfaces as a rejected promise/`Err` instead of a process exit, and the range warning is prepended to the table-style output rather than printed to a separate stderr stream). `lead` also accepts `--cd-scale` in the WASM terminal (MBA-1411) — since it already applies a loaded table unconditionally (see the drag-table note above), a table trued via `--cd-scale` elsewhere needs the same scale here or its truing is lost; native `lead` has no `--drag-table`/`--cd-scale` of its own, so this is WASM-only, like the drag-table application itself.

#### BC5D Correction Tables

BC5D (5-Dimensional BC Correction) tables provide ML-derived, velocity-dependent BC corrections for specific calibers. These tables capture how BC changes throughout the flight envelope based on weight, BC, muzzle velocity, current velocity, and drag model.

**Auto-Download Mode (Requires `--online` feature):**

Tables are automatically downloaded from the server and cached locally:

```bash
# Auto-download tables (downloads on first use)
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --bc-table-auto

# Force re-download cached tables
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 --bc-table-auto --bc-table-refresh

# Use custom server URL
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
  --bc-table-auto \
  --bc-table-url https://your-server.com/bc5d
```

**Local Directory Mode:**

```bash
# Use predownloaded tables from a local directory
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 \
  --bc-table-dir ./bc_tables/
```

**Available Calibers:** .224, .243, .264, .277, .284, .308, .338

**Cache Locations:**
- macOS: `~/Library/Caches/ballistics-engine/bc5d/`
- Linux: `~/.cache/ballistics-engine/bc5d/`
- Windows: `%LOCALAPPDATA%\ballistics-engine\cache\bc5d\`

When a caliber isn't available, you'll see a helpful message:
```
Warning: No BC5D table available for caliber 0.375 (9.5mm)
         Available calibers: .224, .243, .264, .277, .284, .308, .338
         Continuing without BC5D correction table.
```

Both correction-table lookups — the single-file `--bc-table` correction and the
caliber-specific `--bc-table-dir`/BC5D tables above — are **G1/G7 only**: a wider drag
model warns and is treated as G1 for the lookup, same as `true-velocity`/`plan-truing`.

#### BC and Velocity Truing

Adjust BC and velocity based on real-world chrono data and field observations:

```bash
# BC truing - multiply stated BC by adjustment factor (e.g., 0.85 = 85%)
./ballistics trajectory -v 2822 -b 0.270 -m 140 -d 0.264 \
  --bc-adjustment 0.85

# Velocity truing - add offset to base velocity from chronograph data
./ballistics trajectory -v 2822 -b 0.270 -m 140 -d 0.264 \
  --velocity-adjustment 53   # Adds 53 fps to base velocity

# Combined truing
./ballistics trajectory -v 2822 -b 0.270 -m 140 -d 0.264 \
  --bc-adjustment 0.85 \
  --velocity-adjustment 53
# Result: velocity=2875 fps, BC=0.2295
```

#### CSV Profile and Location Support

Load gun profiles and shooting locations from CSV files for batch processing:

**Gun Profile CSV Format** (`gun_profiles.csv`):
```csv
#RIFLE_NAME,VELOCITY,BC,BC_TYPE,BULLET_WEIGHT,CALIBER,ZERO_TEMP,ZERO_ALT,VELOCITY_ADJ,BC_ADJ
AR22,1115,0.138,G1,40,0.22,32,1370,1,1.0
R700_65CM,2822,0.270,G7,140,0.264,57,1806,53,0.85
```

**Location CSV Format** (`locations.csv`):
```csv
LOCATION_NAME,ALTITUDE,PRESSURE,TARGET_TEMP
KF_LR,2506,27.29,32
Home_Range,500,29.92,70
```

**Usage:**
```bash
# Load from profile CSV
./ballistics trajectory \
  --profile gun_profiles.csv \
  --profile-row R700_65CM \
  -m 140 -d 0.264 \
  --max-range 1000

# Load profile + location
./ballistics trajectory \
  --profile gun_profiles.csv --profile-row R700_65CM \
  --location locations.csv --site KF_LR \
  -m 140 -d 0.264

# CLI args override CSV values
./ballistics trajectory \
  --profile gun_profiles.csv --profile-row R700_65CM \
  --velocity 2900 \   # Overrides CSV velocity
  -m 140 -d 0.264
```

### Importing profiles (.a7p)

Import an ArcherBC2 `.a7p` profile into the local profile store:

```bash
# Preview what would be imported (nothing is written)
ballistics profile import my-rifle.a7p --dry-run

# Import under the file's own profile name (sanitized)
ballistics profile import my-rifle.a7p

# Import under a chosen name; fail hard on checksum mismatch
ballistics profile import my-rifle.a7p --name match-338 --strict
```

The import prints a full mapping report: every field it imported (source
value, converted value, destination), every field it could NOT map (for
example powder temperature sensitivity, scope click offsets, and the
device's range-card list), and any warnings (checksum mismatch). Imported
profiles are stored in metric units and can be recalled by name with
`--profile <name>` (on `mpbr`, `come-ups`, `lead`, `wind-card`, `stability`,
and `range-table`) or `--saved-profile <name>` on `trajectory`, which
reserves `--profile` for CSV gun-profile files.

**Multi-BC and CUSTOM drag curves (MBA-1323 Phase 2).** A `.a7p` file with
more than one G1/G7 coefficient row imports ALL rows as a velocity-banded BC
schedule (`bc_segments` in the saved profile JSON), not just the fastest
row — the scalar `bc` field is still set to the fastest row's value for
tools that only understand one BC. A file with `bc_type CUSTOM` (a full
Mach/Cd drag curve) imports as `drag_model: "CUSTOM"` with the curve stored
in `drag_curve`; because no single coefficient applies to a full curve, the
saved profile's `bc` field is set to `0.0` — an intentionally invalid
sentinel, physically inert once the curve is in use, that makes any
consumer which still expects a scalar BC fail loudly instead of silently
solving under an assumed G1 model.

These two fields are consumed automatically by `trajectory --saved-profile`,
`come-ups --profile`, and `lead --profile`: a saved profile's `bc_segments`
feeds the same velocity-keyed BC schedule as `--bc-segment`/`--bc-table-dir`
(only when the run did not already supply one of those), and `drag_curve`
feeds the same custom drag table as `--drag-table` (only when the run did
not already supply `--drag-table`). `mpbr`, `wind-card`, `stability`, and
`range-table` do not yet consume these two fields from a saved profile —
a profile with multi-BC/CUSTOM data still works there, but only via its
scalar `bc`/`drag_model` fallback. `profile show` prints a summary line
(row/point count and range) for whichever of the two is present.

**DSF (drop-scale-factor) table (MBA-1357).** A profile can also carry `dsf_points`, a
Mach-keyed table of drop corrections accumulated by the `dsf` command (see
[DSF Truing](#dsf-drop-scale-factor-truing) below) — `None`/absent for a profile with no
DSF calibration. `profile show` renders it, one line per point:
```
DSF table (2 points, Mach 0.65-0.95):
  Mach 0.65  DSF 1.0820
  Mach 0.95  DSF 1.0310
```
`profile save NAME ... --clear-dsf` removes an existing table; without `--clear-dsf`,
re-saving a profile (e.g. to tweak an unrelated field) carries its DSF table forward
unchanged — `profile save` has no flags of its own to express DSF points, so silently
dropping them on every unrelated edit would be hostile. `trajectory --saved-profile` and
`come-ups --profile` automatically apply a profile's DSF table to the solved drop and
print a table-output-only note; see [DSF Truing](#dsf-drop-scale-factor-truing).

**Turret click graduations (MBA-1355).** `profile save` also accepts
`--elevation-click <SIZE><UNIT>` and `--windage-click <SIZE><UNIT>` (e.g. `0.1mil` or
`0.25moa`), stored as the profile's `elevation_click`/`windage_click` fields and shown by
`profile show`. Both are validated with the same parser `--elevation-click-value` uses,
at save time, so a saved profile can never contain a graduation `resolve_click_values`
would later reject. They are angular graduations, not linear measurements, so unlike most
profile fields they are **not** rescaled when a profile is loaded under the other
`--units` system. See [Turret Adjustment Units](#turret-adjustment-units) above for how
they're resolved against the `--elevation-click-value`/`--windage-click-value` CLI flags.

**Reading a v2 profile with an older tool: one-way forward-incompatibility.**
`bc_segments` and `drag_curve` are additive JSON keys (unknown-key-tolerant,
default-on-absence), so a profile saved by this version always deserializes
cleanly in an older `ballistics` build, an un-updated WASM build, or a
binding that hasn't been regenerated against this schema. For a `CUSTOM`
(full drag-curve) profile that's safe: the older reader still sees
`drag_model: "CUSTOM"` and the inert `bc: 0.0` sentinel, so it refuses to
solve rather than guessing (`bc_value must be finite and greater than
zero`). For a **multi-BC** profile it is *not* safe: the older reader has no
way to know `bc_segments` exists, silently ignores it, and solves using only
the scalar `bc` (the fastest row) for the entire trajectory. This produces a
materially different, unwarned answer whenever the slower bands matter — for
example, a profile whose bands span a wide velocity range showed a ~639 m/s
impact velocity under the scalar-only fallback versus ~411 m/s with the full
schedule applied, for the identical saved profile. There is no equivalent
sentinel guard available for `bc_segments` (a real, plausible BC value is
required there for single-BC tools to keep working at all), so this
particular skew — new profile, old reader, multi-BC case — degrades
silently by design. Do not exchange saved profile JSON across
`ballistics`/WASM/binding versions that straddle this feature (MBA-1323
Phase 2) unless the older side only ever reads single-BC, non-`CUSTOM`
profiles.

The `.a7p` wire format is implemented independently for interoperability;
no third-party code is bundled.

### Canted Shooting

Model a rifle that is zeroed level but *fired* with the scope/receiver rotated about the
line of sight — the classic "canted rifle" error. Available via `--cant <DEGREES>`
(alias `--cant-angle`) on the `trajectory` and `monte-carlo` subcommands. Default `0` =
level (bit-identical to a solve without the flag).

**Not available on `zero`.** Zeroing always solves the un-canted trajectory — cant is
applied only at fire time. This models "zero the rifle level, then shoot it canted," not
"the rifle was canted while zeroing," which would (mostly) cancel out.

**Sign convention:** positive degrees = clockwise cant as seen from behind the rifle (the
top of the scope tips to the right). For a rifle with an upward zero elevation
correction — the normal case — that rotates the correction partly into windage, so point
of impact moves **right and low** relative to the un-canted zero.

**Error model:** cant rotates the sight-frame aim offsets (elevation and windage) about
the line of sight, and swings the bore's sight-height offset laterally with it. For small
cant angles the combined lateral error at a given range is approximately

```
lateral_error ≈ (D − sight_height) · sin(cant)
```

where `D` is the height the zero's elevation correction adds at that range (i.e. how much
higher the zeroed, un-canted trajectory sits than a flat 0°-elevation shot would). Since
`D` grows with range (and with how much the load has dropped), **cant error grows with
range** — a small cant is barely noticeable at zero range and increasingly costly beyond
it. This is validated in `tests/canted_fire.rs` to within 5% (windage) / 10% (elevation)
of the analytic prediction at 300 m and 600 m.

**Which elevation rotates — matching the field rule of thumb.** `--cant` rotates the
elevation that is *in the gun* (`muzzle_angle`, i.e. whatever the zero/dial put there).
Two scenarios that are easy to conflate:

- **Zeroed at a near distance, never re-dialed** (e.g. `--auto-zero 100`, then read
  windage at long range): only the small 100 yd zero elevation rotates, so the lateral
  error is nearly **constant in mils** across range (≈ `zero_elevation · sin(cant)` —
  roughly 0.1 mil for 5° of cant on a typical rifle zero).
- **Dialed (or held over) for the engagement range, then canted** — the realistic
  long-range case: model it by zeroing at the engagement distance
  (`--auto-zero 1000 --cant 5`). The *entire* come-up rotates, reproducing the classic
  field rule `lateral ≈ come_up · sin(cant)`. Example: a .224 77 gr at 2650 fps (G7 BC 0.372) dialed
  from 100 yd to 1000 yd carries ≈ 9.7 mil of total launch elevation; at 5° of cant the
  engine puts the shot 0.86 mil right — `9.7 · sin(5°) ≈ 0.85 mil`. The two rules agree;
  they just answer different questions.

**Worked example** (build first with `cargo build`; `--sample-interval` is always meters
regardless of `--units`, so `91.44` below is exactly 100 yd):

```bash
# Level (no cant)
./target/debug/ballistics trajectory -v 2700 -m 168 -d 0.308 --bc 0.5 \
  --auto-zero 100 --max-range 600 \
  --sample-trajectory --sample-interval 91.44 -o csv --full

# Same load, 10 degrees of clockwise cant
./target/debug/ballistics trajectory -v 2700 -m 168 -d 0.308 --bc 0.5 \
  --auto-zero 100 --max-range 600 --cant 10 \
  --sample-trajectory --sample-interval 91.44 -o csv --full
```

Level output — `drift_in` is exactly zero at every range:

```
distance_yd,drop_in,drift_in,velocity_fps,energy_ft-lb,time_s
0.00,2.00,0.00,2700.00,2718.96,0.0000
100.00,0.00,0.00,2519.55,2367.68,0.1150
200.00,3.48,0.00,2346.19,2053.06,0.2384
300.00,13.29,0.00,2179.61,1771.88,0.3711
400.00,30.43,0.00,2019.83,1521.62,0.5141
500.00,56.11,0.00,1867.27,1300.44,0.6686
```

10-degree canted output — the right-and-low effect grows with range, so `drift_in` and
`drop_in` climb steadily above the level case with distance. Right at the muzzle it's the
opposite: the bore itself swings toward the cant pivot before the zero's elevation
correction has any range to leak into windage, so `drop_in` briefly runs *lower* than
level (1.97 vs 2.00 at 0 yd) before flipping higher by 100 yd:

```
distance_yd,drop_in,drift_in,velocity_fps,energy_ft-lb,time_s
0.00,1.97,-0.35,2700.00,2718.96,0.0000
100.00,0.04,0.43,2519.55,2367.68,0.1150
200.00,3.59,1.22,2346.19,2053.06,0.2384
300.00,13.46,2.00,2179.61,1771.88,0.3711
400.00,30.67,2.78,2019.83,1521.62,0.5141
500.00,56.42,3.56,1867.27,1300.44,0.6686
```

The small offset already present at the muzzle (`drift_in = -0.35` at 0 yd) is the bore
itself swinging laterally below the canted sight; windage then climbs through positive
(rightward) values as the zero's elevation correction leaks into windage with range.

**Monte Carlo caveat:** cant is a *systematic* aim bias, not a dispersion source. Because
`monte-carlo` reports statistics as deviations about its own (canted) mean, `--cant` shifts
the whole cloud together and has almost no effect on the reported spread — expect the
dispersion numbers to look essentially the same as a level run. Use `trajectory --cant` to
see the point-of-impact shift itself.

### Moving-Target Lead

Calculate the hold needed to hit a target moving at a constant ground speed across (or
along) the line of sight, swept over a range of distances. Available via the `lead`
subcommand.

```bash
./ballistics lead -v 2700 -m 168 -d 0.308 -b 0.5 --target-speed 3
```

Besides the usual load/atmosphere/wind arguments shared with `trajectory` (`-v -b -m -d
--drag-model --sight-height --temperature --pressure --humidity --altitude --wind-speed
--wind-direction`, plus `--profile`), `lead` also accepts `trajectory`'s powder-temperature
flags — `--use-powder-sensitivity`, `--powder-temp-sensitivity`, `--powder-temp`, and
`--powder-temp-curve` — plumbed identically (MBA-1325: both commands build the same
`BallisticInputs` and resolve the correction in the same place,
`cli_api::TrajectorySolver::new`), so a `lead` run can reproduce a powder-corrected muzzle
velocity without a separate `trajectory` call first. See the [Trajectory
Command](#trajectory-command) parameters table for their units/defaults; omitting all four is
unchanged from before (`-v` used verbatim).

`lead` then adds its own moving-target arguments:

- **`--target-speed <SPEED>`** (required) — target ground speed, mph under imperial units,
  m/s under metric.
- **`--target-angle <DEGREES>`** — direction of target *travel* relative to the line of
  sight (default `90`):
  - `0` = directly away (outbound)
  - `90` = crossing left-to-right (full broadside)
  - `180` = directly toward (inbound)
  - `270` = crossing right-to-left
- **`--target-length <LENGTH>`** — target body length (inches imperial, mm metric). When
  given, an extra `Bodies` column reports lead as a multiple of the target's length
  (`lead ÷ target_length`) — a common visual hold reference ("hold one body-length ahead").
- **`--start` / `--end` / `--step`** — range sweep in yards (imperial) or meters (metric),
  like the other sweep tables; defaults `100`/`600`/`100`.
- **`--adjustment-unit <mil|moa|smoa|iphy>`** — angular unit for the `Lead` column
  (default `mil`). See [Turret Adjustment Units](#turret-adjustment-units) for the full
  unit list — `clicks` is **not** available here (`lead` is out of scope for MBA-1355;
  passing it exits non-zero).
- **`-o, --output <table|json|csv|pdf>`** — output format (`pdf` renders the same as `table`
  on this subcommand).

**Locked conventions:**

- **Positive lead = hold in the target's direction of travel.** For a 90° (left-to-right)
  crosser, positive lead is a hold to the right.
- **`lead_mil = (lead/range)·1000`; `lead_moa = (lead/range)·3438`** — the same dial
  convention used by every other hold table in this CLI (MBA-724): MOA is exactly 3.438×
  MIL, not the geometrically exact 3437.7467×.
- **Lead is pure target motion, additive to your wind-corrected hold.** Time of flight
  comes from the engine's wind-aware trajectory solve, but wind deflection itself is *not*
  folded into the lead number — it stays in the separate wind column of your dope; add the
  two holds together.
- **Time of flight is wind-aware** — the underlying solve accounts for wind drag effects on
  TOF even though the lead figure itself reports only target-motion offset.
- **Non-perpendicular motion (angle ≠ 90°/270°) shifts the intercept range.** An outbound
  or inbound target has moved farther or closer by the time the bullet arrives, so
  `calculate_lead` fixed-point iterates `R = R₀ + v_radial·TOF(R)` until the correction is
  below 0.1 m, and reports TOF/lead at that corrected range — the table's `Intercept`
  column shows the range actually used, which differs from the requested `Range` for any
  non-perpendicular angle. A target closing faster than the geometry allows (or one whose
  corrected range runs past the solved trajectory span) produces a typed error printed
  inline in place of that row's data instead of a bogus number.

**Worked example** (build first with `cargo build`):

```bash
./target/debug/ballistics lead -v 2700 -m 168 -d 0.308 -b 0.5 --target-speed 3
```

```
Moving-Target Lead Table (target speed: 3.0 mph, angle: 90°, MIL)
Positive lead = hold in the direction of target travel.

┌──────────┬──────────┬──────────┬──────────┬──────────┐
│Range (yd)│TOF (s)   │Lead ( yd)│Lead (MIL)│Intercept │
├──────────┼──────────┼──────────┼──────────┼──────────┤
│      100 │    0.115 │     0.17 │    1.687 │    100.0 │
│      200 │    0.238 │     0.35 │    1.748 │    200.0 │
│      300 │    0.371 │     0.54 │    1.814 │    300.0 │
│      400 │    0.514 │     0.75 │    1.885 │    400.0 │
│      500 │    0.669 │     0.98 │    1.961 │    500.0 │
│      600 │    0.836 │     1.23 │    2.043 │    600.0 │
└──────────┴──────────┴──────────┴──────────┴──────────┘
```

For a pure 90° crosser the `Intercept` column always equals `Range` (no radial motion to
correct for). The MIL figure climbs slowly with range because the bullet slows down, so
each added yard of range costs more time of flight — and more lateral target travel per
yard — than the last.

`-o json` (trimmed to the first two rows):

```json
{
  "adjustment_unit": "MIL",
  "distance_unit": "yd",
  "rows": [
    {
      "intercept_range": 100.0,
      "iterations": 0,
      "lead": 0.16871387472963065,
      "lead_mil": 1.6871387472963066,
      "lead_moa": 5.800383013204702,
      "range": 100.0,
      "tof_s": 0.11503218731565724
    },
    {
      "intercept_range": 200.0,
      "iterations": 0,
      "lead": 0.34969310252646174,
      "lead_mil": 1.7484655126323088,
      "lead_moa": 6.011224432429878,
      "range": 200.0,
      "tof_s": 0.2384271153589512
    }
  ],
  "target_angle": 90.0,
  "target_speed": 3.0,
  "target_speed_unit": "mph",
  "units": "imperial"
}
```

`lead_moa / lead_mil` is 3.438 on every row, confirming the dial convention above.
`iterations: 0` on a perpendicular crosser — there's no radial motion to fixed-point
iterate on.

Non-perpendicular motion with body-length holds:

```bash
./target/debug/ballistics lead -v 2700 -m 168 -d 0.308 -b 0.5 --target-speed 15 \
  --target-angle 45 --target-length 40 --start 200 --end 400 --step 100
```

```
Moving-Target Lead Table (target speed: 15.0 mph, angle: 45°, MIL)
Positive lead = hold in the direction of target travel.

┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│Range (yd)│TOF (s)   │Lead ( yd)│Lead (MIL)│Intercept │Bodies    │
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│      200 │    0.240 │     1.24 │    6.184 │    201.2 │     1.12 │
│      300 │    0.374 │     1.94 │    6.419 │    301.9 │     1.74 │
│      400 │    0.518 │     2.69 │    6.671 │    402.7 │     2.42 │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘
```

At 45° the target has an outbound (receding) component, so `Intercept` runs a yard or two
past the requested `Range` — the iteration converges on the slightly longer range the
bullet actually has to cover by the time it arrives. `Bodies` reports lead as a multiple of
the 40" target length (e.g. "hold 1.1 body-lengths ahead" at 200 yd).

**Library API:** for programmatic use, `ballistics_engine::calculate_lead(inputs, wind,
atmo, target_speed_mps, target_angle_deg, range_m) -> Result<LeadSolution, LeadError>` runs
the same wind-aware solve and intercept-range iteration directly, without going through the
CLI. `LeadSolution` carries `time_of_flight_s`, `lead_m`, `lead_mil`, `lead_moa`,
`corrected_range_m`, and `iterations`; `LeadError` is a typed enum covering invalid input,
an over-closing (`TargetOvertakesShooter`) target, iteration `Convergence` failure, a
corrected range that runs `BeyondSolvedSpan`, and an underlying trajectory-solve failure
(`Solver`).

### Mover Ring (`--target-speed`)

A field-tested alternative to `lead` for engaging movers (MBA-1325): instead of computing a
directional hold, `trajectory --target-speed <SPEED>` derives a **ring radius** —
`target_speed × time-of-flight-to-that-point` — around your hold point at every printed/
exported trajectory point. Watch the target through your optic; fire the instant it crosses
into the ring. Because ring size only needs time of flight (which the trajectory solve already
produced) and target speed, it falls out of an already-solved trajectory as pure
post-processing — no second command, no re-entered ballistic data, and no assumed crossing
angle (unlike `lead --target-angle`).

```bash
./ballistics trajectory -v 2700 -m 168 -d 0.308 -b 0.5 --target-speed 3 --full
```

- **`--target-speed <SPEED>`** — mph under imperial units, m/s under metric (same convention
  and same `0`–`300` accepted range as `lead --target-speed`; out-of-range values are
  rejected, not clamped). `0` (the default) leaves every output format byte-identical to a
  run without the flag. This is the same flag that drives the PDF dope card's `Lead` column
  (see [PDF Dope Card Format](#pdf-dope-card-format)) — setting it turns on both at once.
- **`--adjustment-unit <mil|moa|smoa|iphy|clicks>`** — angular unit for the ring
  **table** column only (default `mil`; the flag trajectory already exposes for the PDF
  dope card). With `moa` the column reads `Ring(moa)` with `ring_moa = ring_mil × 3.438`
  — the CLI's locked printed-table dial convention (MBA-724, deliberately not the
  exact-angle 3437.7467/1000), so Ring keeps the same MIL/MOA ratio as every other hold
  column; `smoa`/`iphy` share that ratio too (see [Turret Adjustment
  Units](#turret-adjustment-units)). With `clicks` the column reads `Ring(clicks)` and
  rounds to whole turret clicks against the resolved **elevation** graduation (the Ring
  isn't cleanly an elevation- or windage-axis hold, so it reuses the same graduation as
  the dope card's Drop column) — requires `--elevation-click-value` or a saved profile's
  `elevation_click`. CSV keeps `ring_mil` and JSON keeps `mover_ring_m`/`mover_ring_mil`
  regardless — their names are the unit contract.

**Table** (`--full -o table`) gains a `Ring(mil)` column (`Ring(moa)`/`Ring(smoa)`/
`Ring(iphy)`/`Ring(clicks)` under the matching `--adjustment-unit`). The muzzle point
prints `-` (no flight time has elapsed, so the ring has no defined angular size there
yet):

```
Trajectory Points:
┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│ Time (s) │  X (yd)  │  Y (yd)  │ Vel(fps) │Energy(ft-lb)│ Ring(mil)│
├──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│    0.000 │     0.00 │     1.67 │  2700.00 │  2718.96 │        - │
│    0.152 │   130.61 │     1.55 │  2465.74 │  2267.62 │     1.71 │
│    0.302 │   248.75 │     1.21 │  2264.16 │  1912.00 │     1.78 │
│    0.452 │   357.43 │     0.66 │  2086.99 │  1624.49 │     1.85 │
│    0.582 │   444.86 │     0.03 │  1950.48 │  1418.91 │     1.92 │
└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘
```

**JSON** (`--full -o json`) adds two per-point fields, present only when `--target-speed > 0`:

```json
{
  "time": 0.17187200000000002,
  "x": 0.0,
  "y": 1.513531761659063,
  "z": 146.9565529362786,
  "velocity": 2437.286215359467,
  "energy": 2215.5854770501155,
  "mover_ring_m": 0.23050097664000005,
  "mover_ring_mil": 1.715329655579473
}
```

- **`mover_ring_m`** — linear ring radius in **meters**, always, regardless of `--units` — the
  unit is in the field name so it can't be silently misread as something else (the legacy
  `trajectory[].x`/`y`/`z` fields don't have that luxury, being bare numbers; see the `legend`
  block documented under [JSON Format](#json-format), MBA-1315, for how those are labeled
  instead). Present at every point once the flag is on, including the muzzle (`0.0`, since
  `target_speed × 0 = 0`).
- **`mover_ring_mil`** — `mover_ring_m / downrange_m × 1000`. **Omitted** at the muzzle
  (`downrange = 0`, the ratio is undefined), not emitted as `0` or `null`.

**CSV** (`--full -o csv`) gains a trailing `ring_mil` column — the header carries the unit; the
muzzle row's field is empty rather than `0`:

```
time_s,x_yd,y_yd,z_yd,velocity_fps,energy_ft-lb,ring_mil
0.0000,0.00,1.67,0.00,2700.00,2718.96,
0.0010,0.00,1.67,0.90,2698.34,2715.63,1.630
0.0028,0.00,1.67,2.52,2695.37,2709.63,1.631
```

**Reading the ring correctly:**

- **It's a worst-case bound, not an exact intercept solution — it assumes the hold point sits
  on the mover's track.** If the mover is heading straight for your hold point, firing the
  instant it enters the ring puts the bullet there exactly when the mover arrives (both cover
  their respective distance — bullet's flight, mover's ring radius — in the same time). If your
  hold point is only *near* the mover's actual line of travel rather than on it, entering the
  ring no longer means "arrives at the hold point in one ToF"; treat the ring as "no later than
  now," not a promise of an exact hit. Use `lead --target-angle` instead when you know the
  crossing angle and want the angle-aware exact hold — the ring trades that precision for not
  needing to know the angle at all.
- **The mil value is only near-constant across a short stage — it grows slowly with range, not
  a single number for the whole sheet.** In the table above it climbs from 1.71 to 1.92 mil
  over 130–445 yd: the bullet decelerates, so later range gates cost more time of flight per
  yard, and the mover's linear ring radius grows faster than downrange distance does. Read the
  ring size for the range you're actually engaging at.
- **Doesn't fold in wind.** Like `lead`'s hold, the ring is pure target-motion bookkeeping —
  it stays separate from the wind column/dial on the same dope.

### Terminal Chart (`--plot`)

Render two stacked inline terminal charts after the normal `trajectory` output (MBA-1320):
drop vs. range, then lateral drift vs. range. Pure Rust, zero new dependencies — no
terminal-graphics crate, no terminal-size detection, no ANSI colors.

```bash
# Bare --plot: default Unicode braille-dot renderer
./ballistics trajectory -v 2700 -m 168 -d 0.308 -b 0.5 --wind-speed 10 --wind-direction 90 --plot

# --plot ascii: '*'-per-cell fallback for terminals/fonts without braille glyph coverage
./ballistics trajectory -v 2700 -m 168 -d 0.308 -b 0.5 --wind-speed 10 --wind-direction 90 --plot ascii
```

- **`--plot`** (bare) — the default renderer: each terminal character cell packs a 2 (wide)
  x 4 (tall) grid of dots into one Unicode Braille Patterns glyph (`U+2800`–`U+28FF`),
  giving roughly 4x the vertical and 2x the horizontal resolution of one character cell.
- **`--plot ascii`** — a `'*'`-per-cell fallback for terminals/fonts without full braille
  glyph coverage. Same dot-addressed layout and axis scaling; only the dot canvas itself
  changes between the two styles — the frame (`┌─┐│└┘`) and axis-range text
  stay ordinary Unicode box-drawing either way, since those glyphs have near-universal font
  support (unlike the braille block, which is why it needs a fallback at all).
- Only affects `-o table` (the default output format). `-o json`/`-o csv`/`-o pdf` are
  completely unaffected, so scripts parsing those formats never see chart text, and
  omitting `--plot` leaves every output format byte-identical to a pre-MBA-1320 run.
- Fixed 72x12-cell canvas per chart. There's no terminal-size detection — that would need a
  dependency this feature deliberately doesn't take on.
- Deliberately monochrome: no ANSI color/SGR codes anywhere in the renderer. That sidesteps
  `NO_COLOR` (<https://no-color.org/>) entirely — there's nothing to suppress — and keeps
  output byte-identical whether the terminal honors color, redirects to a file, or is a
  dumb pipe.
- Both charts plot the SAME per-point data the `--full` "Trajectory Points:" table prints
  (`result.points`, the raw un-decimated integration output — `--plot` works without
  `--full` too, it just doesn't print the table itself). Drop is the table's `Y` column
  (vertical position), lateral drift is the table's downrange-paired `Z` column (not
  printed by the table by default) — both in the same range unit (yd/m) the rest of the
  table uses, **not** inches. This is a different, deliberate convention from
  `--sample-trajectory`'s sight-line-relative `drop_m`/`wind_drift_m` (see
  [Trajectory Sampling for Analysis](#trajectory-sampling-for-analysis)); don't conflate
  the two.

Example (`--plot ascii`, 10 mph 90° crosswind):

```
Drop vs Range:
┌ drop (yd) — y:[0.00, 1.67] ────────────────────────────────────────────┐
│** ** *** ** *** *** *                                                  │
│                      *** *** *                                         │
│                               *** ***                                  │
│                                      ** **                             │
│                                           **** *                       │
│                                                 ***                    │
│                                                    *** *               │
│                                                         ***            │
│                                                            ***         │
│                                                               ***      │
│                                                                  ** *  │
│                                                                      **│
└ x:[0.00, 448.79] ──────────────────────────────────────────────────────┘

Lateral Drift vs Range:
┌ drift (yd) — y:[-0.44, 0.00] ──────────────────────────────────────────┐
│** ** *** ** *** ***                                                    │
│                     **** *** *                                         │
│                               *** **                                   │
│                                     *** **                             │
│                                           ****                         │
│                                                ****                    │
│                                                    ***                 │
│                                                        ****            │
│                                                            ***         │
│                                                               ***      │
│                                                                  ** *  │
│                                                                      **│
└ x:[0.00, 448.79] ──────────────────────────────────────────────────────┘
```

### Wind Card

Generate a wind-drift dope card: deflection at a sweep of ranges, one column per wind
speed. Available via the `wind-card` subcommand.

```bash
./ballistics wind-card -v 2700 -m 168 -d 0.308 -b 0.5 --zero-distance 100
```

Besides the usual load/atmosphere arguments shared with `trajectory` (`-v -b -m -d
--drag-model --sight-height --temperature --pressure --humidity --altitude`, plus
`--profile`), `wind-card` adds:

- **`--zero-distance <DIST>`** (required) — zero distance, yards imperial / meters metric.
- **`--wind-speeds <CSV>`** — comma-separated wind speeds, one column per value (default
  `5,10,15,20`, mph imperial / m/s metric).
- **`--wind-angle <DEG>`** — a single wind angle in degrees, wind-FROM convention (same as
  `--wind-direction` on `trajectory`): **`0` = headwind, `90` = from the right (full
  value), `180` = tailwind, `270` = from the left.** Mutually exclusive with
  `--wind-angles`.
- **`--wind-angles <CSV>`** — comma-separated wind angles; emits one *complete* card per
  angle (e.g. `--wind-angles 30,60,90`). Mutually exclusive with `--wind-angle`.
- **`--start` / `--end` / `--step`** — range sweep, like the other sweep tables; defaults
  `100`/`1000`/`100`.
- **`--adjustment-unit <mil|moa|smoa|iphy>`** — angular unit for the drift columns
  (default `mil`). See [Turret Adjustment Units](#turret-adjustment-units) — `clicks` is
  **not** available here (`wind-card` is out of scope for MBA-1355; passing it exits
  non-zero).
- **`-o, --output <table|json|csv|pdf>`** — output format (`pdf` renders the same as
  `table` on this subcommand).

**Default (no flags) is the classic full-value 90° card, unchanged.** With neither
`--wind-angle` nor `--wind-angles`, the card is computed at a fixed 90° (full-value
crosswind from the right) and the output is byte-identical to the pre-oblique-angle CLI:
table title says "full-value crosswind" with no angle suffix, and JSON carries
`"crosswind": "full-value (90°)"` instead of a `wind_angle` key.

**Sign convention:** drift values are signed the same way as `drift_in`/`x_yd` elsewhere in
this CLI — positive = rightward (dial right), negative = leftward (dial left). Because the
default card is wind *from the right*, its values run negative; `--wind-angle 270` (wind
from the left) produces the exact mirror image — equal magnitude, opposite sign. `0°`
(headwind) and `180°` (tailwind) both drift to `0.0` (no crosswind component).

**Each angle's cells are a real solve, not a scaled copy.** `wind-card` doesn't take the
90° column and multiply by `sin`/`cos` of the requested angle — every `(speed, angle)`
pair runs its own full trajectory solve. The result tracks `sin(angle)` of the full-value
column closely (within ~1% for the pairs checked) but is not an exact match, because drag
and time-of-flight differ slightly between a purely crosswind solve and an oblique one.

**CSV/JSON self-identify when non-default.** A card produced by `--wind-angle` or
`--wind-angles` prints a `# wind_angle=<DEG>` comment line above its CSV header (one per
card, blank-line separated for multi-angle output) and carries a `"wind_angle"` key in its
JSON object. The legacy no-flag card has neither — it keeps the original `"crosswind"` key
and no CSV comment line, so existing parsers of the default card don't need to change.
Requesting more than one angle (`--wind-angles`) changes the JSON shape from a single
object to an array of one object per angle, in the order given.

**Worked example** (build first with `cargo build`):

```bash
# Default: full-value 90° crosswind card
./target/debug/ballistics wind-card -v 2700 -m 168 -d 0.308 -b 0.5 --zero-distance 100 --end 300

# Same load, a single oblique 45° card
./target/debug/ballistics wind-card -v 2700 -m 168 -d 0.308 -b 0.5 --zero-distance 100 --end 300 --wind-angle 45
```

Default card:

```
Wind Card (zero: 100 yd, MIL, full-value crosswind)
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│Range (yd)│       5 mph │      10 mph │      15 mph │      20 mph │
├──────────┼──────────┼──────────┼──────────┼──────────┤
│      100 │     -0.1 │     -0.2 │     -0.3 │     -0.4 │
│      200 │     -0.2 │     -0.4 │     -0.6 │     -0.8 │
│      300 │     -0.3 │     -0.6 │     -0.9 │     -1.2 │
└──────────┴──────────┴──────────┴──────────┴──────────┘
```

45° card — smaller-magnitude drift than the full-value 90° card, at every speed and range,
because only part of the wind is perpendicular to the shot:

```
Wind Card (zero: 100 yd, MIL) — wind angle 45° (wind-FROM: 0=head, 90=right, 180=tail, 270=left)
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│Range (yd)│       5 mph │      10 mph │      15 mph │      20 mph │
├──────────┼──────────┼──────────┼──────────┼──────────┤
│      100 │     -0.1 │     -0.1 │     -0.2 │     -0.3 │
│      200 │     -0.1 │     -0.3 │     -0.4 │     -0.6 │
│      300 │     -0.2 │     -0.4 │     -0.7 │     -0.9 │
└──────────┴──────────┴──────────┴──────────┴──────────┘
```

### Vertical Wind

Model wind with a vertical component — a thermal updraft, a downdraft off the lee side of
a ridge, orographic lift along a slope — as a direct shift in point of impact, on top of
the horizontal drift `--wind-speed`/`--wind-direction` already model. Available via
`--wind-vertical <SPEED>` on both the `trajectory` and `monte-carlo` subcommands, and as an
optional 4th field on `--wind-segment`.

**Sign convention:** positive = updraft, negative = downdraft. An updraft raises point of
impact; a downdraft lowers it. Units follow `--units` the same way `--wind-speed` does: mph
under imperial, m/s under metric. Default `0` (no vertical wind, bit-identical to a solve
without the flag).

```bash
--wind-vertical 5     # metric: 5 m/s updraft, raises POI
--wind-vertical -8    # imperial: 8 mph downdraft, lowers POI
```

**Downrange segments — the optional 4th field.** `--wind-segment` accepts an optional 4th
colon-separated field: `SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL]`, letting each segment carry
its own vertical wind:

```bash
ballistics trajectory -v 2700 -m 168 -d 0.308 --bc 0.5 --max-range 1000 \
  --wind-segment 8:90:300:5 \
  --wind-segment 12:90:1000:10
```

- **VERTICAL is always m/s, positive = updraft — regardless of `--units`.** This is a
  deliberate asymmetry with SPEED (and UNTIL_DISTANCE), which *do* follow `--units`: SPEED
  matches the display system so a wind-meter reading in mph can be pasted directly, but
  there's no comparably universal display convention for vertical wind, so it's pinned to
  the engine's native m/s to keep the field unambiguous no matter what `--units` is active.
- Omitting the 4th field is unchanged from before — that segment's vertical wind is `0.0`.
  Every existing 3-field `--wind-segment SPEED:ANGLE:UNTIL_DISTANCE` string keeps working
  exactly as it did.

**Shear pass-through.** `--enable-wind-shear`'s boundary-layer models (logarithmic /
power-law / Ekman spiral) scale **horizontal** wind speed with altitude only. Vertical wind
passes through unscaled wherever shear is layered on top of it — a 5 m/s updraft is a
5 m/s updraft at every altitude the shot climbs through, shear on or off. (`--wind-segment`
itself is not compatible with `--enable-wind-shear` — see below — so in practice this rule
matters for the scalar `--wind-speed`/`--wind-vertical` + `--enable-wind-shear`
combination.)

**Precedence.** `--wind-segment`, when given, overrides the scalar wind entirely —
including `--wind-vertical`. Once segments are set, each segment's own 4th field is the
only source of vertical wind; the scalar `--wind-vertical` value is ignored (a note is
printed to stderr, the same override behavior `--wind-speed`/`--wind-direction` already have
against `--wind-segment`).

**Drag-symmetry fact.** Aerodynamic drag doesn't distinguish "up" from "sideways" — a
projectile flying into a 5 m/s updraft is deflected vertically by essentially the same
mechanism that deflects it laterally in a 5 m/s crosswind. `tests/vertical_wind.rs` locks
this as a regression gate: a 5 m/s updraft's vertical deflection and a 5 m/s crosswind's
lateral deflection must agree within 5% at 300 m and 600 m. The two solves in fact agree
far more tightly than the bound requires — about 0.001% at 600 m in the current build.

**Monte Carlo caveat:** `--wind-vertical` on `monte-carlo` sets the *base* (mean) vertical
wind shared by every simulated shot — it is a systematic input, not a dispersion source.
There is no `--wind-vertical-std`; each sampled wind draw carries the same vertical
component through un-dispersed, while horizontal wind speed/direction still vary per
`--wind-std`/`--wind-direction-std`. Expect `--wind-vertical` to shift the whole impact
cloud's mean point of impact without changing its reported spread — the same pattern as
`--cant` (see the Canted Shooting Monte Carlo caveat above).

**Worked example** (build first with `cargo build`):

```bash
# Calm baseline
./target/debug/ballistics trajectory --units metric -v 823 -m 10.9 -d 7.82 --bc 0.5 \
  --drag-model g7 --auto-zero 500 --max-range 600 \
  --sample-trajectory --sample-interval 100 -o csv --full

# Same load, 5 m/s updraft
./target/debug/ballistics trajectory --units metric -v 823 -m 10.9 -d 7.82 --bc 0.5 \
  --drag-model g7 --auto-zero 500 --max-range 600 --wind-vertical 5 \
  --sample-trajectory --sample-interval 100 -o csv --full
```

Calm baseline:

```
distance_m,drop_m,drift_m,velocity_m/s,energy_J,time_s
0.00,0.05,0.00,823.00,3691.44,0.0000
100.00,-0.30,0.00,792.49,3422.84,0.1238
200.00,-0.49,0.00,762.61,3169.54,0.2525
300.00,-0.51,0.00,733.35,2931.05,0.3862
400.00,-0.36,0.00,704.75,2706.89,0.5253
500.00,0.00,0.00,676.82,2496.58,0.6701
600.00,0.57,0.00,649.57,2299.59,0.8209
```

5 m/s updraft — `drop_m` runs smaller at every range past the zero, and by 600 m point of
impact sits about 8 cm higher than the calm run (`0.49` vs `0.57`):

```
distance_m,drop_m,drift_m,velocity_m/s,energy_J,time_s
0.00,0.05,0.00,823.00,3691.44,0.0000
100.00,-0.25,0.00,792.49,3422.85,0.1238
200.00,-0.41,0.00,762.61,3169.55,0.2525
300.00,-0.44,0.00,733.35,2931.06,0.3862
400.00,-0.30,0.00,704.75,2706.89,0.5253
500.00,-0.00,0.00,676.82,2496.56,0.6701
600.00,0.49,0.00,649.57,2299.55,0.8209
```

### Zero Calculation

Calculate sight adjustments for specific distances:

```bash
# Calculate zero for 200 yards
./ballistics zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 200

# With custom sight height
./ballistics zero -v 2700 -b 0.475 -m 168 -d 0.308 \
  --target-distance 300 \
  --sight-height 0.055  # 2.2 inches in yards

# Metric
./ballistics zero --units metric -v 823 -b 0.475 -m 10.9 -d 7.82 \
  --target-distance 200  # 200 meters
```

Output provides:
- Zero angle in degrees
- MOA adjustment
- Mrad adjustment
- Maximum ordinate

### Load Comparison (`compare`)

Run several loads through identical conditions and see them side by side. Each load is
zeroed independently at the shared `--zero-distance`, then solved twice (a no-wind pass
for pure drop, a wind pass for drift), exactly like `range-table`:

```bash
# Two loads by inline spec: NAME:DRAG:BC:MASS:VELOCITY[:DIAMETER]
ballistics compare \
  --load "175 SMK:g7:0.243:175:2650" \
  --load "168 ELD-M:g7:0.523:168:2700" \
  --zero-distance 100 --end 800 --step 100

# Mix inline specs with saved profiles; MOA adjustments; machine output
ballistics compare --load "Factory:g1:0.475:168:2700" --profile my-match-load \
  --zero-distance 100 --adjustment-unit moa -o json
```

Load-spec fields follow the session `--units`: `MASS` is grains (imperial) or grams
(metric), `VELOCITY` fps or m/s, and the optional `DIAMETER` inches or mm (defaulting to
.308 in / 7.82 mm). `DRAG` is any of `g1`/`g2`/`g5`/`g6`/`g7`/`g8`/`gi`/`gs`/`ra4`, and
`NAME` may not contain `:`. Between 2 and 8 loads are accepted, from `--load` and/or
`--profile` in any combination. A saved
profile's velocity-BC segments and custom Cd(Mach) drag curve (e.g. from an `.a7p`
import) ARE consumed here — they drive both the load's zeroing and its trajectory runs,
and such loads are tagged `[BC segments]` / `[custom drag curve]` in the table legend
(and flagged in JSON). Inline `--load` specs use the scalar BC.

The table shows per-load drop, drift (both in the `--adjustment-unit <mil|moa|smoa|iphy>`
— see [Turret Adjustment Units](#turret-adjustment-units); `clicks` is **not** available
here, `compare` is out of scope for MBA-1355 and exits non-zero), and velocity at
each range. `-o json` adds linear drop/drift, energy, time of flight, each load's zero
angle, and per-row deltas against load #1 (`delta_drop`, `delta_drift`, `delta_velocity`,
`delta_energy` — zero for the baseline itself); `-o csv` emits one column group per load
(names sanitized for CSV). PDF output is not supported for this command.

### Powder Temperature Velocity (`powder`)

Resolve the powder-temperature-adjusted muzzle velocity without running a trajectory.
The physics is the exact resolution the `trajectory` and `lead` solvers apply
internally (one shared implementation). One flag difference: `powder` always applies
the linear model, while `trajectory`/`lead` only apply it when you also pass
`--use-powder-sensitivity` (a measured curve applies there unconditionally) — carry
that flag along or your trajectory will fly the nominal velocity:

```bash
# Linear model: 2800 fps load (measured at the default 70 °F reference), 40 °F day
ballistics powder -v 2800 --temperature 40
```

```
Powder Temperature Velocity
===========================
  Model:              linear, 1.00 fps/°F
  Reference temp:     70.0 °F
  Nominal velocity:   2800.0 fps

  Shot temp:          40.0 °F
  Resolved velocity:  2770.0 fps  (-30.0)
```

```bash
# Measured curve (overrides the linear model): interpolate at a 55 °F powder temp
ballistics powder --powder-temp-curve "40:2620,70:2700,100:2760" --powder-temp 55

# Velocity ladder across a temperature range, with muzzle energy
ballistics powder -v 2700 -m 168 --sweep 20:110:30
```

```
Powder Temperature Velocity
===========================
  Model:              linear, 1.00 fps/°F
  Reference temp:     70.0 °F
  Nominal velocity:   2700.0 fps

   Temp (°F)  Velocity (fps)   Shift (fps)   Energy (ft·lb)
        20.0          2650.0         -50.0             2619
        50.0          2680.0         -20.0             2679
        80.0          2710.0          10.0             2739
       110.0          2740.0          40.0             2800
```

Flags follow the session `--units` (fps + °F imperial; m/s + °C metric) and carry the
same meanings as on `trajectory`/`lead`: `--powder-temp-sensitivity` defaults to
1.0 fps/°F (0.54864 m/s/°C); `--powder-temp` is the linear model's *reference*
temperature — the temperature the stated `-v` velocity was measured at, defaulting to
70 °F — or, with `--powder-temp-curve`, the powder temperature the curve is
interpolated at (defaulting to `--temperature`, i.e. powder at air temperature; the
curve is clamped at its endpoints, never extrapolated). With a curve the sweep
temperatures are powder temperatures, and `-v` is optional — the curve supplies the
velocity, `-v` only anchors the reported shift. `-m/--mass` (grains/grams) adds muzzle
energy (ft·lb / J). Output: table (default), `-o json`, or `-o csv`; PDF is not
supported for this command.

### Monte Carlo Simulation

Statistical analysis with parameter variations:

```bash
# Basic Monte Carlo
./ballistics monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -n 1000

# With variations and target distance
./ballistics monte-carlo \
  -v 2700         # Base velocity (fps)
  -b 0.475        # Base BC
  -m 168          # Mass (grains)
  -d 0.308        # Diameter (inches)
  -n 1000         # Simulations
  --velocity-std 10    # Velocity std dev
  --angle-std 0.5      # Angle std dev
  --bc-std 0.01        # BC std dev
  --wind-std 2         # Wind std dev
  --target-distance 600 # For hit probability
```

### WEZ (Weapon Employment Zone) Sweep

> Also available in the WASM terminal (ballistics.sh): `monte-carlo --wez` with the
> WEZ flags (`--target-size`, `--wind-call-error`, `--wez-start/-end/-step`, `-o`)
> and output matching the native CLI. The terminal's `monte-carlo` does not expose
> the base-wind or hold flags (`--wind-speed`, `--wind-direction`, `--wind-vertical`,
> `--cant`, `--target-distance`, `--target-radius`), which stay at their defaults;
> it does accept `--drag-model`, which the native command lacks. A sweep runs
> num-sims full solves per range step in the browser — prefer `-n 300` for
> interactive use.

`monte-carlo --wez` answers a different question than the base command above. Instead of a
single summary at one `--target-distance`, it sweeps a range of distances and reports **hit
probability on a fixed target size at each range, holding a single zero** — the classic
"point-blank range" question: *how far out can I engage this target size without holding over
or dialing elevation?*

This matters because it means ballistic **drop below your line of sight counts as a miss
source**, exactly like it would for a real shot fired with that one zero. That is different from
the base `monte-carlo --target-distance` command, whose hit probability is measured against
*that run's own* point of aim — i.e. it implicitly assumes you re-dial correctly for every
range. A WEZ sweep does not assume that; it uses the elevation you pass with `-a/--angle` (from
`ballistics zero`, typically) for every step of the sweep.

```bash
# Zero for 300 yards first
./ballistics zero -v 2700 -b 0.475 -m 168 -d 0.308 --target-distance 300
# -> Zero Angle: 0.1432°

# Sweep 200-1000 yd in 100 yd steps against an 18"x30" target (e.g. IPSC/steel silhouette),
# with a 3 mph wind-call error on top of a 1 mph physical wind-speed uncertainty
./ballistics monte-carlo -v 2700 -b 0.475 -m 168 -d 0.308 -a 0.1432 \
  --wez --target-size 18x30 \
  --wind-call-error 3 --wind-std 1

# WEZ sweep: 1000 sims/step, wind call 3.00 mph + wind std 1.00 mph (quadrature) = 3.16 mph effective
# ┌────────────┬──────────┬───────────────┬───────────┬───────────┬───────────┐
# │ Range ( yd) │  P(hit)  │ Dominant      │ Wind call │  MV SD    │ Other/grp │
# ├────────────┼──────────┼───────────────┼───────────┼───────────┼───────────┤
# │      200.0 │    61.1% │ other         │      0.0% │      0.0% │    100.0% │
# │      300.0 │    37.5% │ other         │      0.0% │      0.0% │    100.0% │
# │      400.0 │    18.7% │ other         │      0.0% │      0.0% │    100.0% │
# │      500.0 │     9.0% │ other         │      0.0% │      0.0% │    100.0% │
# │      600.0 │     3.2% │ n/a           │      0.0% │      0.0% │      0.0% │
# │      700.0 │     0.4% │ n/a           │      0.0% │      0.0% │      0.0% │
# │      800.0 │     0.0% │ n/a           │      0.0% │      0.0% │      0.0% │
# │      900.0 │     0.0% │ n/a           │      0.0% │      0.0% │      0.0% │
# │     1000.0 │     0.0% │ n/a           │      0.0% │      0.0% │      0.0% │
# └────────────┴──────────┴───────────────┴───────────┴───────────┴───────────┘
```

Past a range the (undispersed) trajectory can no longer physically reach — because a nearly
flat shot from a normal bore height eventually crosses the ground plane — `Dominant` and the
share columns read `n/a`: the variance attribution isn't meaningful there, though `P(hit)` is
still correct (and will simply read 0%, since every sample is a miss).

**Wind call vs. ballistic wind.** `--wind-call-error` is the shooter's own uncertainty in
*reading* the wind (e.g. "I think it's 8-10 mph, call it 9") — a human estimation error. It is
distinct from `--wind-std`, which models physical gust-to-gust variability in the wind itself.
Both perturb the same physical channel (the wind speed fed to the trajectory solve), so as
independent random errors they combine **in quadrature**, not by simple addition:

```
effective_wind_std = sqrt(wind_std^2 + wind_call_error^2)
```

`--wind-call-error` defaults to `0.0` (perfect wind call — only `--wind-std` and the other
dispersion sources contribute), matching the base command's existing behavior when `--wez` is
not set.

**Target size** (`--target-size WIDTHxHEIGHT` or `--target-size RADIUS`):
- `18x30` — a rectangle, 18" wide x 30" tall (cm under `--units metric`), centered on the line
  of sight.
- `12` — a single number falls back to a circular hit radius (same units, same "distance from
  point of aim" semantics as the base command's `--target-radius`), just expressed in
  target-size units (inches/cm) instead of range units (yards/meters).

**Variance attribution.** Each row's `Dominant` column and the three share columns (`Wind
call`, `MV SD`, `Other/grp`) report which uncertainty source contributes the most to the miss
variance at that range, and their approximate shares (they sum to ~100%). `Other/grp` bundles
mechanical/ammo group dispersion (`--angle-std`, the derived azimuth spread, `--bc-std`) with
the *ballistic* (non-call) share of wind uncertainty (`--wind-std`, `--wind-direction-std`).

This is computed **analytically** from a linearized (one-standard-deviation finite-difference)
sensitivity of the impact point to each independent source, rather than by re-running the full
Monte Carlo sample set once per bucket with that source zeroed out. A full decomposed re-run
would multiply the sweep's cost by the number of buckets; the linearized estimate instead costs
a handful of extra deterministic trajectory solves per range and keeps the default sweep's
runtime in the same ballpark as the base `monte-carlo` command (a few seconds for the default
9-step sweep). At the magnitude of a single sigma the ballistic response is close enough to
linear for this to be a reasonable first-order error budget — not an exact decomposition.

**Sweep range**: `--wez-start`, `--wez-end` (inclusive), `--wez-step` (all in yards for
imperial, meters for metric; defaults `200`/`1000`/`100`).

**Output**: `-o summary` (default) prints the table above; `-o full` prints JSON with
unit-labeled fields (`range_m`, `p_hit`, `dominant_error_source`, `wind_call_share`,
`mv_sd_share`, `other_share`, plus the resolved `target_size`, `wind_speed_std_mps`,
`wind_call_error_mps`, and `combined_wind_speed_std_mps`); `-o statistics` prints one CSV row
per range step.

**Performance**: `--num-sims` (`-n`, default 1000) is respected per range step, same as the
base command — a WEZ sweep is `--num-sims` trajectories times the number of sweep steps, plus a
handful of cheap deterministic solves per step for attribution.

### BC Estimation

Estimate ballistic coefficient from observed data. Supports both the **G1 and G7** drag
models and two fit bases — a **drop** curve or a downrange **velocity** curve (the latter is
immune to zero / sight-height / launch-angle error). A row is printed for each drag model ×
data basis you supply.

```bash
# Legacy two-point drop input (G1 + G7 by default)
./ballistics estimate-bc \
  -v 2700 -m 168 -d 0.308 \
  --distance1 100 --drop1 0.0 \
  --distance2 200 --drop2 0.023

# n-point drop series, G7 only
./ballistics estimate-bc -v 2650 -m 77 -d 0.224 \
  --data "300,29.0;500,89.9;700,204.6" --drag-model g7

# All four variants: G1/G7 x drop/velocity
./ballistics estimate-bc -v 2650 -m 77 -d 0.224 \
  --data "300,29.0;500,89.9;700,204.6" \
  --velocity-data "300,1980;500,1560;700,1240" \
  --drag-model both
```

Options: `--data "dist,drop;..."` (yd,in / m,mm), `--velocity-data "dist,vel;..."`
(yd,fps / m,m/s), `--drag-model g1|g7|both` (default `both`), `-o table|json|csv`.

**Dope-card (zeroed) data — use `--zero-range` and match the atmosphere.** A dope card's
drops are measured below your line of sight from a rifle **zeroed** at some range (so the
drop is ~0 at the zero and grows downrange). Pass `--zero-range` so the fit matches that
frame, and give the conditions the card was made at — BC only means something relative to
air density:

```bash
./ballistics estimate-bc -v 2650 -m 77 -d 0.224 \
  --data "100,0;300,14.2;500,61.4;700,162.4;900,343.0;1100,643.0" \
  --zero-range 100 --sight-height 2.0 \
  --temperature 59 --pressure 29.92 --altitude 0 --drag-model g7
```

Without `--zero-range`, drop is treated as **bore-referenced** (flat-fire drop below the
extended bore) — correct only for a bore-drop table, not a dope card; the tool warns if your
data looks zeroed. A fit that can't determine a value from the data (too few/short-range
points, or wrong zero/atmosphere) is flagged **UNRELIABLE** rather than returning a bogus
number. Atmosphere flags: `--temperature` (°F/°C), `--pressure` (inHg/hPa), `--humidity`
(%), `--altitude` (ft/m); `--zero-range` (yd/m), `--sight-height` (in/mm).

### True Velocity Calculation

Find the effective muzzle velocity that produces a measured drop at a known range. This helps "true" your ballistic system by identifying discrepancies between chronograph readings and real-world ballistic performance.

```bash
# Basic true velocity calculation (offline)
./ballistics true-velocity \
  --measured-drop 5.1    # Measured drop in MILs
  --range 600            # Range where drop was measured (yards)
  --bc 0.27              # Ballistic coefficient
  --drag-model g7        # G7 drag model
  --mass 140             # Bullet mass (grains)
  --diameter 0.264       # Bullet diameter (inches)
  --offline              # Use local calculation

# With chronograph velocity for comparison
./ballistics true-velocity \
  --measured-drop 5.1 --range 600 \
  --bc 0.27 --drag-model g7 \
  --mass 140 --diameter 0.264 \
  --chrono-velocity 2822 \  # Compare against chrono reading
  --offline

# With BC5D tables for improved accuracy
./ballistics true-velocity \
  --measured-drop 5.1 --range 600 \
  --bc 0.27 --drag-model g7 \
  --mass 140 --diameter 0.264 \
  --bc-table-auto \        # Auto-download BC5D tables
  --offline

# Using online API (with fallback)
./ballistics true-velocity \
  --measured-drop 5.1 --range 600 \
  --bc 0.27 --drag-model g7 \
  --mass 140 --diameter 0.264 \
  --offline-fallback       # Try API, fall back to local if fails

# Metric units
./ballistics true-velocity --units metric \
  --measured-drop 5.1 --range 549 \  # 549 meters ≈ 600 yards
  --bc 0.27 --drag-model g7 \
  --mass 9.07 --diameter 6.71 \      # grams, mm
  --offline
```

#### True Velocity Parameters

| Parameter | Description | Default |
|-----------|-------------|---------|
| --measured-drop | Measured drop at `--range` (MIL by default; follows `--drop-unit` in multi-observation mode) | Required |
| --range | Range where drop was measured | Required |
| --observed | Additional observed impact `RANGE:DROP` (repeatable) — enables joint MV+BC calibration | None |
| --drop-unit | Drop unit for `--measured-drop`/`--observed` in multi-observation mode (`mil`/`moa`/`in`) | mil |
| --bc | Ballistic coefficient (starting value; fitted when observations allow) | Required |
| --drag-model | Drag model (G1/G7) | g1 |
| --mass | Bullet mass | Required |
| --diameter | Bullet diameter | Required |
| --chrono-velocity | Chronograph velocity for comparison | None |
| --zero-range | Zero range | 100 yd/m |
| --sight-height | Sight height above bore | 2.0 in/50mm |
| --bullet-length | Bullet length (for BC5D lookup) | Auto-calculated |
| --offline | Force offline mode (local calculation) | false |
| --offline-fallback | Fall back to local if API fails | false |
| --bc-table-dir | Directory with BC5D tables | None |
| --bc-table-auto | Auto-download BC5D tables | false |

#### Output

The command outputs:
- **Effective Velocity**: The calculated muzzle velocity that produces the measured drop
- **Velocity Adjustment**: Difference from chrono velocity (if provided)
- **Adjustment Percent**: Percentage adjustment from chrono
- **Confidence**: High/Medium/Low based on convergence quality
- **Iterations**: Number of iterations to converge
- **Final Error**: Remaining error in MILs

Example output:
```
True Velocity Results
═════════════════════
Effective Velocity:    2740 fps
Chrono Velocity:       2822 fps
Velocity Adjustment:   -82 fps (-2.91%)
Confidence:            high
Iterations:            12
Final Error:           0.001 MIL
Calculated Drop:       5.10 MIL
```

### DSF (Drop-Scale-Factor) Truing

`dsf` is the second stage of Applied Ballistics' two-stage truing workflow (MBA-1357).
Once `true-velocity` has fixed the supersonic muzzle velocity/BC against a chronograph
and a supersonic (Mach > 1.2) observed drop, drop discrepancies that grow through the
transonic region and into the subsonic regime are no longer fixable by a single MV
correction — the residual is a slowly-varying function of Mach. `dsf` records a handful
of *observed drop / predicted drop* ratios at specific (Mach <= 1.2) ranges and stores
them, keyed by Mach, on a saved profile; `trajectory --saved-profile` and
`come-ups --profile` then scale predicted drop by the nearest table entry automatically.

Unlike every other command, `dsf` takes **no ballistic parameters of its own** — it
solves the named saved profile's own trajectory (same physics `trajectory
--saved-profile NAME` would fly with no other flags) and derives everything else from
`--range` and `--observed-drop`.

```bash
# Record an observed 5.1 mil drop at 900 yards on a profile already MV-trued.
./ballistics dsf --saved-profile my-rifle --range 900 --observed-drop 5.1mil

# Same, drop given in MOA or linear inches instead.
./ballistics dsf --saved-profile my-rifle --range 900 --observed-drop 17.4moa
./ballistics dsf --saved-profile my-rifle --range 1000 --observed-drop 42.0in
```

#### DSF Parameters

| Parameter | Description | Default |
|-----------|-------------|---------|
| `--saved-profile` | Saved profile to solve, calibrate, and write the point back to | Required |
| `--range` | Range at which the drop was observed (yards imperial, meters metric) | Required |
| `--observed-drop` | Observed drop, value and unit with NO separator: `mil`, `moa`, or `in` (e.g. `5.1mil`, `17.4moa`, `42.0in`) | Required |

#### How a point is derived

1. Solves the saved profile's trajectory and finds the predicted drop and Mach number at
   `--range` (Mach = velocity ÷ the solver's station speed of sound — the same divisor
   `apply_dsf` uses later, so a point derived here lands back on the identical Mach when
   applied).
2. `dsf = observed / predicted`, expressed in the same unit `--observed-drop` used.
3. **Staging gates:**
   - An observation whose target-range Mach is **> 1.2** is rejected outright — that's
     `true-velocity`'s territory, not DSF's:
     ```
     error: observation is supersonic (Mach 1.35); calibrate muzzle velocity first (true-velocity), then collect DSF points at Mach <= 1.2
     ```
   - A warning (not fatal) when the observation range is beyond 90% of the trajectory's
     solved max range — the solution's reliability degrades past this point:
     ```
     warning: observation at 950 yd is beyond 90% of the solved range; solution reliability degrades past this point
     ```
   - A warning (not fatal) when the table's highest-Mach point still sits below Mach 0.9
     — nothing in the table covers the 0.9-1.2 transonic band:
     ```
     warning: no DSF point in the transonic band (Mach 1.2-0.9); transonic drops remain uncorrected
     ```
   - A note (stdout, always) naming the DSF *validity window* — at or beyond 90% of the
     trajectory's downward Mach 0.9 crossing — using the same solve `dsf` already
     performed above:
     ```
     note: DSF window: at or beyond 620.4 yd (90% of the Mach 0.9 distance)
     ```
     or, if the trajectory never goes subsonic within the solved range:
     ```
     note: no subsonic window inside the solved range
     ```
4. The point is added to the profile's table (up to 6 distinct points): a new point
   within 0.05 Mach of an existing one **supersedes** it (reported on stdout); a 7th
   distinct point is rejected, naming the 6-point cap and `--clear-dsf` to make room.
5. The profile is saved and the resulting table is printed.

#### Auto-apply

`trajectory --saved-profile NAME` and `come-ups --profile NAME` automatically apply a
profile's DSF table to every solved point's drop (byte-identical velocity/energy/time of
flight/windage — this is a **drop-only** correction) and, for **table output only**,
print:
```
DSF table active (2 points, Mach 0.65-0.95)
```
JSON and CSV output carry the corrected drop numbers too, but get no equivalent text or
extra top-level field — the note above is purely a human-facing display detail.

> The WASM terminal (ballistics.sh) supports DSF truing on `trajectory` too, but has no
> saved-profile storage to carry a table between calls — pass it per call instead with
> one or more repeatable `--dsf-point MACH:DSF` flags (e.g. `--dsf-point 0.65:1.082
> --dsf-point 0.95:1.031`), up to 6. Validation and the auto-apply/note behavior are
> identical to the native CLI's saved-profile path above (MBA-1411). One difference from the
> native `dsf` verb: per-call points are NOT merged — the native verb supersedes points within
> 0.05 Mach of each other, but `--dsf-point` passes your list through as-is, so keep the Mach
> keys distinct.

### MCP Server (`mcp`)

`ballistics mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io/) server over
the stdio transport, so an MCP-aware AI assistant can drive the engine directly instead of
shelling out to the CLI. It speaks newline-delimited JSON-RPC 2.0: one JSON-RPC message per
line on standard input, one JSON-RPC message per line on standard output, exactly as the MCP
stdio transport specifies. Nothing else is ever written to stdout.

```bash
# Run directly (an MCP client normally launches this for you as a subprocess)
./ballistics mcp
```

#### Claude Desktop configuration

Add an entry to Claude Desktop's `claude_desktop_config.json` pointing at the built binary:

```json
{
  "mcpServers": {
    "ballistics-engine": {
      "command": "/absolute/path/to/ballistics",
      "args": ["mcp"]
    }
  }
}
```

Restart Claude Desktop after editing the config. The `command` must be an absolute path; the
binary is discovered from a build (`cargo build --release`, binary at
`target/release/ballistics`) or from an installed release.

#### Implemented methods

| Method | Behavior |
| --- | --- |
| `initialize` | Returns `serverInfo` (`name: "ballistics-engine"`, this crate's version), `capabilities: {"tools": {}}`, and echoes back whatever `protocolVersion` the client requested (falling back to a fixed recent version when the client omits it). |
| `notifications/initialized` | Accepted as a no-op, per the MCP lifecycle. |
| `tools/list` | Returns the two tools below with their JSON Schema `inputSchema`. |
| `tools/call` | Invokes `solve` or `engine_info`; see below. |
| `ping` | Returns an empty result. |

Any other method returns JSON-RPC error `-32601` (Method not found). Malformed JSON returns
`-32700` (Parse error); a structurally invalid JSON-RPC message (not an object, wrong
`jsonrpc` version, missing `method`) returns `-32600` (Invalid Request). A single JSON-RPC
message is capped at 1 MiB; an oversized line is rejected with `-32700` without buffering it in
full, and the session keeps running. Malformed input never terminates the server — only closing
stdin (EOF) does, which exits `0`.

#### Tools

**`solve`** — arguments *are* a [solve-json v1](docs/SOLVE_JSON_V1.md) request object
(`schema_version`, `projectile`, `rifle`, `shot`, `atmosphere`, `wind`, `solver`, `effects`,
`sampling`; explicit SI units throughout). The tool result's text content is the solve-json v1
response JSON — either a success envelope with `resolved_request`/`summary`/`samples`, or a
solve-json v1 error envelope. Arguments that are not even a structurally valid solve-json v1
request (unknown or missing fields, wrong JSON types, an unsupported `schema_version`) are
instead rejected as a JSON-RPC `-32602` (Invalid params) protocol error, with the solve-json v1
error object attached as `error.data`. A well-formed request the engine cannot solve (an
out-of-range value, a resource limit, a genuine solve failure) is reported as a normal
`tools/call` result with `isError: true` instead — see the doc comment in
[`src/mcp_command.rs`](src/mcp_command.rs) for the full rationale behind this split.

**`engine_info`** — no arguments. Returns this crate's version, the drag models solve-json v1
accepts (`G1`, `G6`, `G7`, `G8`), and the crate feature flags this binary was compiled with.

Only these two tools are exposed in this pass; other CLI subcommands (Monte Carlo, BC
estimation, true velocity, profile import, and so on) are not wrapped as MCP tools yet.
#### Joint MV + BC Calibration (multiple observed impacts)

A single observed drop cannot separate muzzle velocity from ballistic
coefficient — mid-range (fully supersonic) drops are dominated by time of flight,
which is set by muzzle velocity, while BC only bites once the bullet bleeds into
the transonic region. Supply **two or more** observed impacts with the repeatable
`--observed RANGE:DROP` flag and `true-velocity` fits **both** muzzle velocity and
BC jointly against the real forward model (a full trajectory solve per candidate),
using damped Gauss-Newton (Levenberg-Marquardt).

The primary `--range`/`--measured-drop` pair is the first observation; each
`--observed` adds another. `RANGE` follows `--units` (yd imperial / m metric) and
`DROP` follows `--drop-unit` (`mil` default, or `moa` / `in`). This mode is always
computed locally.

```bash
# .308 168gr — three drops spanning supersonic -> near-transonic
./ballistics true-velocity \
  --range 300 --measured-drop 1.30 \   # first observation (yd : mil)
  --observed 600:4.40 \                # second observation
  --observed 900:9.00 \                # third observation
  --bc 0.45 --drag-model g1 \          # starting BC (fitted from here)
  --mass 168 --diameter 0.308

# MOA drops instead of MILs
./ballistics true-velocity \
  --range 300 --measured-drop 4.47 \
  --observed 600:15.1 --observed 900:30.9 \
  --drop-unit moa \
  --bc 0.45 --mass 168 --diameter 0.308
```

Example output:
```
=== VELOCITY + BC TRUING (multi-observation) ===

  Fitted muzzle velocity:    2676.2 fps
  Fitted BC:                 0.4813  (input 0.4500)

  Range (yd)  Observed (mil)  Predicted (mil)   Resid (mil)
  --------------------------------------------------------
       300.0           1.300           1.274        -0.026
       600.0           4.400           4.415        +0.015
       900.0           9.000           8.997        -0.003
  --------------------------------------------------------
  RMS residual: 0.018 mil   |   iterations: 4

  Joint MV+BC fit, excellent: RMS residual 0.018 mil, conditioning 148
  Diagnostics: BC sensitivity ratio 0.3013, conditioning 148
  MV-calibration window: 656.7-729.7 yd (90-100% of the Mach 1.2 distance)
  for optimal observation ranges run: ballistics plan-truing
```

**MV-calibration window.** The finally fitted load is re-solved (independent of
the observation set) to find where it crosses downward through Mach 1.2 — the
90-100% span of that distance is the range band where a drop residual most
cleanly identifies muzzle velocity. Table output only. If the trajectory never
crosses downward through Mach 1.2 within a generous fixed envelope, a note
prints instead, and its text depends on *why* there is no crossing. A load
still supersonic at the end of the envelope prints:
```
note: no MV window: trajectory is supersonic through 3109.4 yd; MV is identifiable at any range
```
A load that launches below Mach 1.2 (e.g. a subsonic/suppressed build) and so
never crosses downward at all prints a different note instead:
```
note: no MV window: trajectory never reaches Mach 1.2; calibrate muzzle velocity with a chronograph, then collect DSF points
```
Any observation outside the window gets a per-observation warning on stderr
(regardless of `-o`):
```
warning: observation at 300.0 is outside the MV-calibration window (656.7-729.7); MV fits from this range are weakly identified
```
See [`plan-truing`](#design-an-identifiable-truing-experiment-plan-truing) below
for choosing observation ranges up front instead of diagnosing them after the fact.

**Identifiability / honest refusal.** Before fitting BC, the command measures how
strongly the observation set constrains it: a *BC sensitivity ratio* (relative
influence of a fractional BC change vs a fractional MV change on the predicted
drops) and a *condition number* (collinearity of the MV and BC effects). If the
observations are all short and closely spaced — so a BC change is indistinguishable
from an MV change — the command **refuses the joint fit**, fits muzzle velocity
only, holds BC at the input value, and prints the reason. It never reports a
BC pulled out of ill-conditioned data as if it were precise. To constrain BC, add
a longer-range / transonic observation.

```
=== VELOCITY + BC TRUING (multi-observation) ===

  Fitted muzzle velocity:    2675.6 fps
  BC:                        0.4500  (held; not fitted)
  ...
  MV-only fit, excellent: RMS residual 0.005 mil (BC held fixed)
  Note: observations do not constrain BC (BC sensitivity ratio 0.09 < 0.20
        threshold); BC held at input 0.450. Add a longer-range / transonic
        observation to fit BC.
```

`-o json` reports the fitted values, per-observation residuals, RMS, the
identifiability diagnostics, and a self-describing `legend` with unit-labelled
field names (`range_yd`, `observed_drop_mil`, `predicted_drop_mil`,
`residual_mil`, `rms_residual_mil`, `fitted_muzzle_velocity` + `velocity_unit`).
It also carries the MV-calibration window as two additive fields,
`mv_window_start_m` / `mv_window_end_m` (meters, `null` when there is no
window) — JSON/CSV never get the note text above, only these numbers.

> With zero `--observed` flags the command behaves exactly as the classic
> single-observation velocity truing described above.

> The WASM terminal (ballistics.sh) supports the `true-velocity` command —
> single- and multi-observation — with output matching the native CLI. The
> gaps: the online/BC5D flags (`--bc-table-dir`, `--bc-table-auto`,
> `--bc-table-url`, `--offline-fallback`, `--api-url`, `--api-timeout`) and
> `--bullet-length` are not exposed there (`--offline` is accepted as a no-op;
> the terminal always calculates locally).

#### Design an identifiable truing experiment (`plan-truing`)

`plan-truing` answers the question *before* impacts are collected: which of the
target distances actually available at this facility best separate muzzle
velocity from a scalar BC? It uses the same trajectory solver and central
finite-difference perturbations as `true-velocity`, and it never fabricates an
observation or changes a saved profile.

```bash
# Explicit discrete target stations
./ballistics plan-truing \
  -v 2700 -b 0.475 --drag-model g1 -m 168 -d 0.308 \
  --candidate-ranges 200,300,400,500,600,700,800,900 \
  --observation-count 3 \
  --minimum-separation 100 \
  --measurement-resolution 0.03 \
  --drop-unit mil

# Or explicitly discretize an interval; this expands to 200,300,...,1000
./ballistics plan-truing \
  --profile 308-match \
  --range-grid 200:1000:100 \
  --observation-count 3 \
  --minimum-separation 150 \
  --measurement-resolution 0.05 \
  --output json
```

Ranges and minimum separation follow the global `--units`. The candidate list
and `--range-grid START:END:STEP` conflict: the interval is never silently
discretized at an engine-chosen step. Every selected range is one of the
declared candidates, the requested count is exact, and all selected pairs honor
the minimum separation. For small candidate sets the optimizer exhaustively
checks every feasible combination; large sets use deterministic greedy
construction plus one-for-one exchange. Input order does not change the answer.

`--measurement-resolution` means the independent **1σ standard deviation of one
impact reading** in `--drop-unit` (`mil`, `moa`, or `in`). The report repeats this
assumption and shows how the weak-axis fractional uncertainty scales at half and
twice that sigma. The discrete design is also re-optimized at those two
resolutions; if either assumption changes the selected station set, the report
warns that the recommendation is resolution-sensitive instead of implying it is
robust. It also reports:

- the chosen stations and predicted nominal drop;
- fractional MV and BC sensitivity at each station;
- each selected station's leave-one-out information gain;
- BC sensitivity ratio, condition number, singular values, log determinant, and
  weak-axis fractional 1σ uncertainty;
- eligible-but-unselected and rejected/unreachable candidates with reasons;
- an explicit `mv_only` recommendation when the available ranges do not separate
  MV from BC.

The finite information-gain score is `0.5 log det(I + F)` in fractional MV/BC
coordinates. Here `I` is a disclosed identity reference information matrix
(unit reference covariance for fractional parameter changes), used only to make
the experiment-design score finite. It is not a prior injected into subsequent
truing; the unregularized singular values, determinant, and condition number are
reported alongside it.

Saved profiles are resolved by the native CLI; explicit load/atmosphere flags
override their scalar values. V1 deliberately supports one scalar G1/G7 BC.
The nominal design point must lie inside the joint truing bounds: 1000–5000 fps
after unit conversion and scalar BC 0.05–2.0.
Profiles with velocity-banded BC values or a custom Mach/Cd curve are rejected:
varying a single BC in either model would be physically meaningless. Supporting
a fitted scale for a complete drag deck is a different parameter model.

#### Uncertainty-aware MV + BC truing

The legacy joint fit is intentionally conservative: it reports a point estimate
only when an identifiability gate passes, otherwise it holds BC fixed. For an
explicit probabilistic analysis, declare the measurement errors:

```bash
./ballistics true-velocity \
  --range 500 --measured-drop 3.179 \
  --observed 600:4.349:0.03 \
  --observed 900:8.891:0.02 \
  --observation-sigma 0.03 \
  --bc 0.45 --drag-model g1 --mass 168 --diameter 0.308 \
  --predict-range 1000 \
  --prediction-sigma 0.03 \
  --output json
```

`--observation-sigma SIGMA` supplies the known absolute 1σ error for the primary
`--range`/`--measured-drop` pair and the default for every additional impact.
An optional third field in `--observed RANGE:DROP:SIGMA` overrides it for that
reading. Drop and sigma use `--drop-unit`; range uses `--units`. All sigmas must
be positive and finite. This is weighting, not shot dispersion inferred from the
residuals: a reading with half the sigma receives four times the least-squares
weight.

Optional independent normal priors are explicit:

```text
--mv-prior MEAN:SIGMA     # fps or m/s according to --units
--bc-prior MEAN:SIGMA     # dimensionless
```

No prior is inferred from the input BC, chronograph velocity, a saved profile, or
the residual RMS. `--chrono-velocity` remains a display-only comparison. The MAP
minimizes the weighted observation chi-square plus only the priors shown in the
request. Because observation sigmas are declared as absolute known standard
deviations, the local covariance is `(Jᵀ W J + prior precision)⁻¹`; it is **not**
multiplied by residual variance.

The scalar BC and every prior mean must lie within the constrained joint-fit
bounds (MV 1000–5000 fps after conversion; BC 0.05–2.0). These bounds apply only
to the opt-in uncertainty fit; the legacy point path retains its historical
input contract.

The v1 report contains:

- the weighted joint MAP, per-observation sigma/residual/standardized residual;
- MV and BC 95% local-Gaussian intervals, physical covariance, and correlation;
- data/prior chi-square, effective fitted-parameter count, effective residual
  degrees of freedom, and reduced chi-square when defined;
- warnings for weak BC sensitivity, collinearity, prior domination, interval/bound
  overlap, extrapolation, or an unavailable approximation;
- a latent model-drop interval at every repeatable `--predict-range`;
- when `--prediction-sigma` is supplied, a distinct, wider future-observation
  interval that adds that declared reading error in quadrature.

The approximation is Laplace/Gauss-Newton around a constrained MAP, not MCMC and
not a claim that a multimodal posterior is Gaussian. If the optimizer is not at a
verified stationary point, the information matrix is singular/non-finite, or the
MAP lies on a parameter bound, the MAP and residuals remain available but the
covariance/bands are replaced by a structured failure. Short, clustered ranges
therefore produce broad/prior-dominated BC uncertainty or an explicit inability
to approximate it, never a precise-looking fixed BC.

The diagnostics state how the MAP was verified. `scaled_gradient` means the
Gauss-Newton half-gradient met its scaled tolerance. If the production solver's
fine numerical texture disagrees with that broad finite-difference stencil,
`objective_mesh` means a deterministic eight-direction poll of the actual
penalized chi-square found no improvement larger than `1e-8` down to a scaled
`1e-7` mesh; its radius, largest observed improvement, and evaluation count are
reported rather than hiding the alternate convergence criterion.

This uncertainty surface currently runs in the native CLI and library. With no
uncertainty flag, `true-velocity` takes the existing path unchanged: point
estimates, table/CSV text, and JSON schema remain compatible.

## Output Formats

### Units by Output Surface

The same solved trajectory is expressed differently depending on which output you
ask for — when comparing numbers across surfaces, check which one you are reading:

| Surface | Vertical | Units |
|---|---|---|
| Native `-o json` (`--full` points) | world-frame `x`/`y`/`z`, `y` = height above ground | yd (imperial) / m (metric), per its legend |
| Native table / CSV drop & drift | below the line of sight | inches (imperial) / meters (metric) |
| `solve-json` v1 | below the line of sight (`drop_m`) | always SI (meters) |
| WASM terminal `-o json` | below the line of sight, unit in the key name | `drop_inches`/`drift_inches` (imperial), `drop_cm`/`drift_cm` (metric), per its `units` legend |

Two related cross-system notes:

- The **default bore height** is the round number of each system: 60 in (imperial)
  vs 1500 mm (metric) — 1.524 m vs 1.5 m. The 2.4 cm difference is visible in
  `max_height` and in high-precision imperial-vs-metric parity checks; pass
  `--bore-height` explicitly when comparing systems digit-for-digit.
- The **mover ring** renders as an angular hold (`--adjustment-unit`: mil, moa, smoa,
  iphy, or clicks — see [Turret Adjustment Units](#turret-adjustment-units)) in the
  human table, while CSV keeps `ring_mil` and JSON keeps `mover_ring_m` /
  `mover_ring_mil` regardless — machine columns carry their unit in the name and
  stay stable.


All commands support four output formats via `-o`:

### Table Format (default)
```bash
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 -o table
```

### JSON Format
```bash
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 -o json > trajectory.json
```

**Units and axes (MBA-1315).** The legacy `trajectory[]` points are bare `x`/`y`/`z` numbers
with no unit suffix in the field name, and their axis order surprises tooling written against
a "x=lateral, y=up, z=depth" 3D convention — in this output `x` is lateral and `z` is
downrange. A field tester's tooling once misread `x`/`z` (in **yards**, not feet) as a depth
axis in feet and misdiagnosed a bug. The document carries a `legend` block (appended after
`trajectory`; every pre-existing key/value is unchanged) that states both explicitly:

```json
{
  "units": "imperial",
  "max_range": 25.0,
  "max_height": 1.6666666666666667,
  "time_of_flight": 0.02803675408939502,
  "impact_velocity": 2651.8043404763275,
  "impact_energy": 2622.759081598047,
  "stability_coefficient": 2.000083126080045,
  "spin_drift": null,
  "trajectory": [
    {
      "time": 0.0,
      "x": 0.0,
      "y": 1.6666666666666667,
      "z": 0.0,
      "velocity": 2700.0,
      "energy": 2718.96097071048
    }
  ],
  "legend": {
    "units": {
      "distance": "yd",
      "velocity": "fps",
      "energy": "ft-lb"
    },
    "axes": {
      "x": "lateral offset from the muzzle's initial aiming direction; positive means to the shooter's right (e.g. a crosswind FROM the left, --wind-direction 270, drifts x positive; FROM the right, --wind-direction 90, drifts x negative). Zero at the muzzle.",
      "y": "height above the ground in the world frame; positive means up. Starts near bore height and reaches 0 at ground impact. This is NOT height above the line of sight (compare solve-json v1's LOS-relative drop_m).",
      "z": "downrange distance from the muzzle; zero at the muzzle, always increasing."
    }
  }
}
```

- **`legend.units`** — concrete abbreviation for each numeric quantity family: `distance`
  (`trajectory[].x`/`y`/`z`, `max_range`, `max_height`, `spin_drift`), `velocity`
  (`trajectory[].velocity`, `impact_velocity`), and `energy` (`trajectory[].energy`,
  `impact_energy`). These are `yd`/`fps`/`ft-lb` under `--units imperial` (the default) and
  `m`/`m/s`/`J` under `--units metric` — the same mapping as the top-level `units` field, spelled
  out numerically instead of left for tooling to infer. Angle fields (`max_yaw_angle`,
  `max_precession_angle`, `final_pitch_angle`, `final_yaw_angle`, present only with
  `--enable-precession`) are always radians; time fields are always seconds; neither varies
  with `--units`, so neither is covered by `legend.units`.
- **`legend.axes`** — verified empirically (not assumed) from controlled solves: a pure
  crosswind run and a no-wind run, comparing which point component moved, in which sign
  direction, against the table output. `x` is **lateral** (positive = shooter's right; wind
  FROM the left, `--wind-direction 270`, drifts it positive), `y` is **height above the
  ground** (positive = up; this is a world-frame height, NOT height above the line of sight),
  and `z` is **downrange distance** from the muzzle (always increasing). Note the reversal
  from some 3D conventions: `x` is lateral and `z` is downrange here, not the other way round.

**Mover ring** (`mover_ring_m`/`mover_ring_mil`, only with `--target-speed > 0`) and the
`min_pitch_damping`/`transonic_mach`/`max_yaw_angle`/`max_precession_angle`/
`final_pitch_angle`/`final_yaw_angle` diagnostics (only with `--enable-pitch-damping` /
`--enable-precession`) carry their unit in the field name already and are covered above and
in the [Mover Ring](#mover-ring---target-speed) section, not restated in `legend`.

**WASM `-o json`** (browser/`ballistics.sh` interface) uses a different, already
self-describing shape — `range_yards`/`drop_inches`/`drift_inches` (or the `_meters`/`_cm`
metric equivalents) instead of bare `x`/`y`/`z` — but until MBA-1315 it still left the sign
convention of `drop`/`drift` unstated. It carries the same `legend` block, adapted to its own
field names:

```json
{
  "trajectory": [ { "range_yards": 0.0, "drop_inches": 0.0, "drift_inches": 0.0, "velocity_fps": 2700.0, "energy_ftlb": 2718.96, "time_seconds": 0.0 } ],
  "summary": { "max_range_yards": 25.0, "max_height_inches": 60.0, "time_of_flight_seconds": 0.028, "impact_velocity_fps": 2651.8 },
  "legend": {
    "units": { "range": "yd", "drop": "in", "drift": "in", "velocity": "fps", "energy": "ft-lb" },
    "axes": {
      "range": "downrange distance from the muzzle; zero at the muzzle, always increasing.",
      "drop": "vertical miss from the line of sight; positive means the bullet is below the line of sight (has fallen below the aim point). Not the same reference as the native CLI legacy JSON's world-frame `y`.",
      "drift": "lateral miss from the line of sight; positive means to the shooter's right (e.g. a crosswind FROM the left, --wind-direction 270, drifts it positive; FROM the right, --wind-direction 90, drifts it negative). Same sign and source as the native CLI legacy JSON's `x`."
    }
  }
}
```

`drift` is read off the same underlying value as the native CLI's `x` (both are `position.z`
in the engine's internal frame), so the two agree in sign; `drop` is LOS-relative (subtracts
the line-of-sight height) where the native CLI's `y` is an absolute world-frame height, so the
two do **not** share a reference despite both being "vertical".

### CSV Format
```bash
./ballistics trajectory -v 2700 -b 0.475 -m 168 -d 0.308 -o csv > trajectory.csv
```

### PDF Dope Card Format
Generate a printable dope card with two-column layout, color-coded values, and alternating row stripes for field readability:

```bash
./ballistics trajectory -v 2550 -b 0.236 -m 175 -d 0.308 --drag-model g7 \
  --auto-zero 100 --max-range 1000 \
  --wind-speed 5 --wind-direction 90 \
  --temperature 55 --pressure 27.32 --altitude 2500 \
  --sample-trajectory --sample-interval 9.144 \
  --ignore-ground-impact \
  --target-speed 4 \
  --powder "IMR4320" --bullet-name "SMK" \
  --location-name "General" \
  --profile-row "R700_308" \
  -o pdf --output-file dope_card.pdf
```

**PDF-specific options:**
| Parameter | Description |
|-----------|-------------|
| `--output-file` | Output file path (required for PDF) |
| `--adjustment-unit` | Angular unit for Drop/Wind/Lead columns: `mil` (default), `moa`, `smoa`, `iphy`, or `clicks` — see [Turret Adjustment Units](#turret-adjustment-units) |
| `--elevation-click-value` / `--windage-click-value` | Turret click graduations for `--adjustment-unit clicks`, e.g. `0.1mil` or `0.25moa` — see [Turret Adjustment Units](#turret-adjustment-units) |
| `--target-speed` | Target speed for the Lead column (mph imperial / m/s metric — MBA-1325 fixed this to follow `--units` like every other speed flag; previously always read as mph). Also enables the [Mover Ring](#mover-ring---target-speed) column/fields on every output format |
| `--powder` | Powder type (shown in footer) |
| `--bullet-name` | Bullet name (shown in footer) |
| `--location-name` | Location name (shown in header) |
| `--profile-row` | Rifle name (shown in header) |
| `--font-scale` / `--font-preset` | Data-table font size |
| `--bold-data` | Bold font for data cells |

**PDF features:**
- Two-column table layout with Range (yd) and Drop/Wind/Lead in **MIL, MOA, SMOA, IPHY,
  or whole turret clicks** (via `--adjustment-unit`; drop uses the elevation click
  graduation, Wind/Lead the windage one)
- Color coding: Black=Range, Red=Drop, Green=Wind, Blue=Lead
- Alternating row stripes for easy tracking in field conditions
- Header with rifle, location, density altitude, atmospheric data
- Footer with timestamp, load data, BC, and velocity

## Parameters Reference

### Trajectory Command

| Parameter | Description | Default | Imperial | Metric |
|-----------|-------------|---------|----------|--------|
| -v, --velocity | Muzzle velocity | Required | fps | m/s |
| -a, --angle | Launch angle | 0.0° | degrees | degrees |
| -b, --bc | Ballistic coefficient | Required | - | - |
| -m, --mass | Projectile mass | Required | grains | grams |
| -d, --diameter | Projectile diameter | Required | inches | mm |
| --drag-model | Drag model (g1/g2/g5/g6/g7/g8/gi/gs/ra4) | g1 | - | - |
| --auto-zero | Auto-zero distance | None | yards | meters |
| --zero-velocity | Zero-day muzzle velocity (auto-zero only); overrides both powder models | shot-day velocity | fps | m/s |
| --zero-temperature | Zero-day air temperature (auto-zero only); also resolves linear powder velocity unless `--zero-velocity` is set | shot-day temperature | °F | °C |
| --zero-pressure | Zero-day barometric pressure (auto-zero only) | shot-day pressure | inHg | hPa |
| --zero-humidity | Zero-day relative humidity (auto-zero only) | shot-day humidity | percent | percent |
| --zero-altitude | Zero-day altitude (auto-zero only) | shot-day altitude | feet | meters |
| --zero-powder-temp | Zero-day powder temp for the curve lookup (auto-zero only); otherwise uses explicit --zero-temperature, or inherits shot-day --powder-temp when zero temperature is unchanged. --zero-velocity still wins | zero air / inherited shot powder | °F | °C |
| --powder-temp-curve | Measured `TEMP:VEL,...` powder-temp→velocity table (interpolated at the powder temp, clamped; overrides --powder-temp-sensitivity) | none | °F & fps | °C & m/s |
| --powder-temp | With a curve: powder temp the curve is looked up at (default --temperature). With the linear model: reference temp (default 70/21) | --temperature (curve) / 70°F (linear) | °F | °C |
| --sight-height | Sight height above bore | 0.05 | yards | meters |
| --bore-height | Bore height above ground | 5 | feet | meters |
| --ignore-ground-impact | Disable ground impact detection | false | - | - |
| --max-range | Maximum range | 1000 | yards | meters |
| --time-step | Integration time step — RK4/Euler only (the adaptive RK45 default steps adaptively and ignores this) | 0.001 | seconds | seconds |
| --wind-speed | Wind speed | 0 | mph | m/s |
| --wind-direction | Wind direction (0=headwind, 90=from right, 180=tailwind, 270=from left) | 0° | degrees | degrees |
| --wind-vertical | Vertical wind; positive = updraft (raises POI), negative = downdraft. Also on `monte-carlo` | 0 | mph | m/s |
| --wind-segment | Downrange wind segment `SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL]` (repeatable); VERTICAL is always m/s regardless of `--units` | — | mph & yd | m/s & m |
| --temperature | Temperature | 59 | °F | °C |
| --pressure | Barometric pressure | 29.92 | inHg | hPa |
| --humidity | Relative humidity | 50 | % | % |
| --altitude | Altitude | 0 | feet | meters |
| --use-bc-segments | Enable BC segmentation | false | - | - |
| --bc-segment | Manual velocity-keyed BC segment `VMIN:VMAX:BC` (repeatable) | — | fps | m/s |
| --print-bc-segments | Print the BC5D-generated segment ladder as ready-to-paste `--bc-segment` arguments (requires `--bc-table-dir`) | false | fps | m/s |
| --full | Show all trajectory points | false | - | - |
| --enable-magnus | Enable Magnus effect | false | - | - |
| --enable-coriolis | Enable Coriolis effect | false | - | - |
| --enable-spin-drift | Enable empirical Litz spin drift | false | - | - |
| --twist-rate | Barrel twist rate | 12 | inches/turn | inches/turn |
| --twist-right | Right-hand twist | false | - | - |
| --latitude | Latitude for Coriolis/weather | None | degrees | degrees |
| --longitude | Longitude for weather zones | None | degrees | degrees |
| --shot-direction | Shot azimuth (0=N, 90=E) | None | degrees | degrees |
| --shooting-angle | Incline angle (up/down) | 0 | degrees | degrees |
| --cant | Rifle cant about the line of sight (alias `--cant-angle`); positive = clockwise, POI right and low. Also on `monte-carlo`, not `zero` | 0 | degrees | degrees |
| --enable-wind-shear | Wind shear with altitude | false | - | - |
| --sample-trajectory | Sample at regular intervals | false | - | - |
| --sample-interval | Sampling interval (always meters, not unit-system dependent) | 10 | meters | meters |
| --enable-pitch-damping | Transonic stability analysis | false | - | - |
| --enable-precession | Angular motion physics | false | - | - |
| --use-rk4-fixed | Use fixed-step RK4 instead of adaptive RK45 | false | - | - |
| --target-speed | Moving-target speed, 0–300 (see [Mover Ring](#mover-ring---target-speed)); also drives the PDF dope card's Lead column. `0` disables both | 0 | mph | m/s |
| --plot | Inline terminal chart, drop then lateral drift vs. range (see [Terminal Chart](#terminal-chart---plot)); bare = braille, `--plot ascii` = ASCII fallback. `-o table` only | off | - | - |

### Manual BC Segments (`--bc-segment`)

A bullet's effective BC changes with its **velocity** (it degrades as the bullet slows,
sharpest through transonic). `--bc-segment VMIN:VMAX:BC` (repeatable) lets you supply your
own velocity-keyed BC ladder — the given BC applies while the bullet's current speed is in
`[VMIN, VMAX)`:

```bash
# BC 0.243 above 1800 fps, 0.228 from 1500-1800, 0.205 from 1200-1500
ballistics trajectory -v 2600 -b 0.243 -m 175 -d 0.308 --drag-model g7 --max-range 1000 \
  --bc-segment 1800:4000:0.243 \
  --bc-segment 1500:1800:0.228 \
  --bc-segment 1200:1500:0.205
```

- **VMIN/VMAX** follow `--units` (fps imperial, m/s metric); **BC** is dimensionless.
- Segments are keyed to **velocity**, not distance — this is orthogonal to `--wind-segment`
  (which is distance-keyed). You can combine both; each applies on its own axis.
- Passing any `--bc-segment` implies `--use-bc-segments` and **overrides** `--bc-table` and
  `--bc-table-dir` (manual pairs are highest priority). An interior gap between segments falls
  back to the manually adjusted base `--bc`; outside the global coverage, the nearest segment
  is used.
- To run BC5D-equivalent corrections on a device that cannot hold the tables (e.g. the
  WASM CLI), run once with `--bc-table-dir ... --use-bc-segments --print-bc-segments`:
  the generated ladder prints as ready-to-paste `--bc-segment` lines (velocities in the
  active `--units`). Pasting the full ladder reproduces the table trajectory to well
  under 1%. Note `--bullet-length` is informational for BC5D: the v2 table axes are
  drag type x weight x BC x muzzle velocity x current velocity — length is not a lookup
  dimension.

### Downrange Wind Segments (`--wind-segment`)

Real wind varies along the bullet's path. `--wind-segment SPEED:ANGLE:UNTIL_DISTANCE[:VERTICAL]`
(repeatable) lets you describe wind that changes with downrange distance — for example a
muzzle reading plus downrange sensor stations. The optional 4th field adds a per-segment
vertical wind component — see [Vertical Wind](#vertical-wind) for the sign convention, its
m/s-regardless-of-`--units` field, and the shear/precedence rules:

```bash
# 8 mph at the muzzle, 12 mph past 300 yd, 18 mph past 600 yd (all from the right)
ballistics trajectory -v 2600 -b 0.243 -m 175 -d 0.308 --max-range 1000 \
  --wind-segment 8:90:300 \
  --wind-segment 12:90:600 \
  --wind-segment 18:90:1000
```

- **SPEED** and **UNTIL_DISTANCE** follow `--units` (mph & yards imperial, m/s & meters
  metric). **ANGLE** is degrees in the wind-FROM convention, same as `--wind-direction`
  (0 = headwind, 90 = from the right, 180 = tailwind, 270 = from the left).
- Each segment applies from the previous boundary out to its `UNTIL_DISTANCE`. The wind
  is a **step function** — there is no interpolation between segments.
- **Wind is zero beyond the last segment.** If your segments don't reach `--max-range`,
  a coverage warning is printed; extend the last segment past the target to avoid it.
- `--wind-segment` **overrides** `--wind-speed`/`--wind-direction` (a note is printed if
  both are given), and is **not compatible with `--enable-wind-shear`**.

### Online Mode Parameters (--online)

When using `--online`, calculations are routed through the Flask API for ML-enhanced predictions:

| Parameter | Description | Default |
|-----------|-------------|---------|
| --online | Route through Flask API | false |
| --api-url | API endpoint URL | https://api.ballistics.7.62x51mm.sh |
| --api-timeout | Request timeout (seconds) | 10 |
| --offline-fallback | Fall back to local if API fails | false |
| --compare | Compare local vs API results | false |
| --enable-weather-zones | Enable weather zone generation | false |
| --enable-3d-weather | Enable altitude weather corrections | false |
| --wind-shear-model | Wind shear model (none/logarithmic/power_law/ekman_spiral) | logarithmic |
| --weather-zone-interpolation | Zone interpolation (linear/cubic/step) | linear |

**Note:** Weather features require `--latitude`, `--longitude`, and `--shot-direction`. Negative values need equals format: `--longitude=-115.2`

`--online` (and `--compare`, which builds the same remote request) routes through the
Flask API, a separate HTTP service that is **G1/G7 only** — a wider drag model warns and
is treated as G1 for that remote request, same as `true-velocity`/`plan-truing`.

### BC5D Table Parameters

BC5D (5-Dimensional BC Correction) tables provide ML-derived corrections for improved accuracy:

| Parameter | Description | Default |
|-----------|-------------|---------|
| --bc-table-dir | Directory with BC5D table files | None |
| --bc-table-auto | Auto-download BC5D tables (online feature) | false |
| --bc-table-url | Base URL for BC5D downloads (online feature) | https://ballistics.tools/downloads/bc5d |
| --bc-table-refresh | Force re-download even if cached (online feature) | false |

**Note:** `--bc-table-auto`, `--bc-table-url`, and `--bc-table-refresh` require the `online` feature. Use `--bc-table-dir` for fully offline operation with pre-downloaded tables.

### True Velocity Command

| Parameter | Description | Default | Imperial | Metric |
|-----------|-------------|---------|----------|--------|
| --measured-drop | Measured drop in MILs | Required | MIL | MIL |
| --range | Range where drop was measured | Required | yards | meters |
| -b, --bc | Ballistic coefficient | Required | - | - |
| --drag-model | Drag model (G1/G7) | g1 | - | - |
| -m, --mass | Bullet mass | Required | grains | grams |
| -d, --diameter | Bullet diameter | Required | inches | mm |
| --chrono-velocity | Chronograph velocity for comparison | None | fps | m/s |
| --zero-range | Zero range | 100 | yards | meters |
| --sight-height | Sight height above bore | 2.0 | inches | mm |
| --bullet-length | Bullet length (for BC5D lookup) | auto | inches | mm |
| --temperature | Temperature | 59 | °F | °C |
| --pressure | Barometric pressure | 29.92 | inHg | hPa |
| --humidity | Relative humidity | 50 | % | % |
| --altitude | Altitude | 0 | feet | meters |
| --offline | Force offline mode | false | - | - |
| --offline-fallback | Fall back to local if API fails | false | - | - |
| --bc-table-dir | Directory with BC5D tables | None | - | - |
| --bc-table-auto | Auto-download BC5D tables | false | - | - |

**Note:** The true-velocity command works in both online and offline modes. Use `--offline` for fully local calculation, or omit for API-based calculation (requires `online` feature).


## Practical Examples

### Hunting Zero at 200 Yards
```bash
# Calculate zero
./ballistics zero -v 2650 -b 0.460 -m 180 -d 0.308 --target-distance 200

# Verify with trajectory
./ballistics trajectory -v 2650 -b 0.460 -m 180 -d 0.308 \
  --auto-zero 200 --max-range 400 --full
```

### Long Range Precision
```bash
./ballistics trajectory \
  -v 2850 -b 0.690 -m 230 -d 0.338 \
  --drag-model g7 \
  --use-bc-segments \
  --auto-zero 100 \
  --max-range 1500 \
  --wind-speed 10 \
  --wind-direction 270 \
  --altitude 5000 \
  --full
```

### Load Development Comparison
```bash
# Load 1: Higher velocity
./ballistics monte-carlo -v 2750 -b 0.475 -m 168 -d 0.308 \
  -n 1000 --velocity-std 15 --target-distance 600

# Load 2: More consistent
./ballistics monte-carlo -v 2680 -b 0.475 -m 168 -d 0.308 \
  -n 1000 --velocity-std 8 --target-distance 600
```

### Varmint Trajectory
```bash
./ballistics trajectory \
  -v 3200 -b 0.242 -m 55 -d 0.224 \
  --auto-zero 200 \
  --max-range 500
```

### Wind Shear and Atmospheric Effects
```bash
# Enable wind shear for altitude-dependent wind
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --wind-speed 10 \
  --wind-direction 90 \
  --enable-wind-shear \
  --altitude 5000 \
  --max-range 1000
```

### Trajectory Sampling for Analysis
```bash
# Sample trajectory at 25-yard intervals
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --sample-trajectory \
  --sample-interval 25 \
  --max-range 1000 -o json > sampled_trajectory.json
```

### Transonic Stability Analysis
```bash
# Enable pitch damping for transonic stability warnings
./ballistics trajectory \
  -v 3000 -b 0.475 -m 168 -d 0.308 \
  --enable-pitch-damping \
  --max-range 2000
```

### Precession and Nutation Physics
```bash
# Enable angular motion modeling
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --twist-rate 10 \
  --enable-precession \
  --max-range 1000
```

### Advanced Physics - Magnus and Spin Drift
```bash
# Enable Magnus effect and spin drift for precision calculation
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --twist-rate 10 \
  --twist-right \
  --enable-magnus \
  --enable-spin-drift \
  --wind-speed 10 \
  --wind-direction 90 \
  --max-range 1000

# Left-hand twist barrel (omit --twist-right)
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --twist-rate 12 \
  --enable-magnus \
  --enable-spin-drift \
  --max-range 1000
```

### Coriolis Effect for Extreme Long Range
```bash
# Northern hemisphere shot, eastward
./ballistics trajectory \
  -v 2850 -b 0.690 -m 230 -d 0.338 \
  --enable-coriolis \
  --latitude 45 \
  --shot-direction 90 \
  --max-range 2000

# Complete advanced physics
./ballistics trajectory \
  -v 3000 -b 0.750 -m 250 -d 0.338 \
  --drag-model g7 \
  --twist-rate 8.5 \
  --twist-right \
  --enable-magnus \
  --enable-coriolis \
  --enable-spin-drift \
  --latitude 38.5 \
  --shooting-angle 45 \
  --wind-speed 15 \
  --wind-direction 270 \
  --altitude 6000 \
  --temperature 25 \
  --pressure 25.5 \
  --humidity 30 \
  --max-range 3000
```

**Shot direction matters (Eötvös effect, fixed in 0.21.0):** with `--enable-coriolis`
and `--latitude`, the `--shot-direction` bearing (0=N, 90=E, 180=S, 270=W) changes the
vertical correction. An **east** shot is lifted (`+2Ω·cos(latitude)·v_east`) and prints
slightly higher; a **west** shot is depressed and prints lower; north/south sit in
between. The horizontal (left/right) Coriolis drift is essentially direction-independent
in the northern hemisphere (always to the right). Prior to 0.21.0 `--shot-direction` was
ignored by the local solver, so east and west gave identical output.

### Online Mode with ML Enhancements
```bash
# Basic online calculation
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --auto-zero 100 --max-range 1000 \
  --online

# Online with weather zones and 3D weather
./ballistics trajectory \
  -v 2850 -b 0.690 -m 230 -d 0.338 \
  --drag-model g7 \
  --auto-zero 100 --max-range 2000 \
  --latitude 36.6 --longitude=-115.2 --shot-direction 90 \
  --enable-weather-zones \
  --enable-3d-weather \
  --wind-shear-model logarithmic \
  --online

# Compare local vs API results
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --auto-zero 100 --max-range 1000 \
  --online --compare
```

### Extreme Weather Conditions
```bash
# Cold, low pressure, high humidity (poor conditions)
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --auto-zero 100 \
  --temperature -10 \  # Very cold
  --pressure 28.50 \   # Low pressure storm
  --humidity 95 \      # Near saturation
  --altitude 7000 \    # High altitude
  --max-range 500

# Hot, dry, high pressure (good conditions) 
./ballistics trajectory \
  -v 2700 -b 0.475 -m 168 -d 0.308 \
  --auto-zero 100 \
  --temperature 95 \   # Hot day
  --pressure 30.50 \   # High pressure
  --humidity 10 \      # Very dry
  --altitude 0 \       # Sea level
  --max-range 500
```

## Advanced Features

### Drag Models
`--drag-model` (`-d` on some commands) accepts the full standard-projectile family, every
one backed by its own real Mach-indexed reference table (MBA-1386) — none of them fall
back to another curve:

- **G1**: Standard flat-base projectile (most common)
- **G2**: Aberdeen J projectile
- **G5**: Short 9° boat-tail
- **G6**: Flat-base secant-ogive
- **G7**: Long 7.5° boat-tail (better for long range)
- **G8**: Flat-base 10° secant-ogive
- **GI**: Blunt/flat-nose, flat base
- **GS**: Round-nose sphere
- **RA4**: British RA 1929 reference drag function (McCoy, *Modern Exterior Ballistics*)
- Full drag tables with Mach-indexed coefficients
- Transonic corrections applied automatically
- Standard drag tables are used without an automatic Reynolds multiplier; a low-Re helper remains available through the Rust API only
- An unrecognized `--drag-model` string (a typo, or a family the library doesn't know) still prints a `warning:` to stderr and falls back to G1 for that run.
- `true-velocity` and `plan-truing`'s forward model is deliberately **G1/G7 only** — an
  unsupported family on those two commands warns and coerces to G1, same as before.
- **GL** (a lower-drag long-range family) is explicitly out of scope: its only public
  source is velocity-domain data, which doesn't fit this engine's Mach-indexed table
  format. Not planned unless a Mach-indexed source turns up.

### BC Modeling
- **BC Segmentation**: Velocity-dependent BC based on bullet type
- **Form Factor**: Additional corrections for bullet shape
- Automatic bullet type identification from parameters

### Physics Engine
- **Integration Methods**:
  - RK45 (Dormand-Prince adaptive) - default for best accuracy
  - RK4 (Runge-Kutta 4th order fixed-step) - available with `--use-rk4-fixed` flag
- Full 3D trajectory integration with six-state modeling
- Magnus effect for spin drift
- Coriolis effect (with latitude input)
- Variable atmospheric conditions
- **Wind Shear**: Altitude-dependent wind profiles
  - Power law model
  - Logarithmic model
  - Exponential decay model
- **Trajectory Sampling**: Regular interval data collection
- **Transonic Effects**:
  - Automatic drag corrections in transonic regime
  - Pitch damping analysis for stability
  - Wave drag modeling
- **Angular Motion**:
  - Precession physics
  - Nutation modeling
  - Gyroscopic stability calculations
- Ground impact detection

#### Advanced Physics Notes
- **Spin Drift**: Requires `--enable-magnus` or `--enable-coriolis` plus `--enable-spin-drift`
- **Magnus Effect**: Side force from spinning projectile, requires `--twist-rate` specification
- **Coriolis Effect**: Earth rotation effects, requires `--latitude` and `--shooting-angle`
- **Twist Direction**: Use `--twist-right` for right-hand twist, omit for left-hand twist
- **Wind Shear**: Models wind speed increase with altitude, affects long-range shots
- **Trajectory Sampling**: Use with JSON/CSV output for detailed analysis
- **Pitch Damping**: Warns about transonic instability (Mach 0.8-1.2)
- **Precession/Nutation**: Models angular motion of spinning projectiles
- **Integration Method**: RK45 adaptive is default (most accurate), RK4 fixed-step available for speed
- Both Magnus and spin drift work together to model the complete gyroscopic effects

### Atmospheric Modeling
- **Temperature Effects**: Affects air density and speed of sound
- **Pressure Effects**: Direct impact on air density (drag)
- **Humidity Effects**: 
  - Humid air is less dense (reduces drag)
  - Increases speed of sound slightly
  - Uses Arden Buck equations for vapor pressure
- **Altitude Effects**: Automatic pressure/density reduction with elevation
- **ICAO Standard Atmosphere**: Full implementation up to 84km
- **CIPM Formula**: Precise air density calculations with humidity

## Notes

- Default units are Imperial (fps, grains, yards)
- All internal calculations use SI units for precision
- BC values are dimensionless (same for G1 and G7)
- Wind direction: 0° = headwind, 90° = from right, 180° = tailwind, 270° = from left
- Trajectory stops at ground impact or max range
- Sight height default is 1.8 inches (0.05 yards) above bore
- Bore height default is 5 feet (1.5 meters) above ground - adjust for shooting position (e.g., 2ft prone, 4ft sitting, 5ft standing)