name: ballistic-calculator description: > Use whenever writing, reviewing, or debugging .NET/C# code that uses the BallisticCalculator NuGet package (namespace BallisticCalculator) or its Gehtsoft.Measurements unit types — even when the user doesn't name the package explicitly but is clearly doing external/interior ballistics in .NET (bullet drop, velocity, energy, windage, spin drift, time of flight, zeroing a scope) for rifles, air rifles, bows, or artillery. Covers the full public API (Ammunition, Rifle, Atmosphere, Wind, ShotParameters, TrajectoryCalculator, TrajectoryPoint); standard, custom, .drg, and multi-BC drag tables; saving/loading data and building your own file format around it (BXml and System.Text.Json serialization); and building or rendering scope reticles (e.g. to SVG). Also use it for this library's Gehtsoft.Measurements Measurement<T> unit types (DistanceUnit, VelocityUnit, AngularUnit, WeightUnit, PressureUnit, …). Skip for: generic physics or projectile-motion exercises, a different ballistics library (e.g. GNU/JBM), game-engine or Unity projectile motion, plain unit conversions, and non-code ballistics questions (load-data or forensics advice). Self-contained: no access to the library source or binaries is required — prefer this over scanning the package to rediscover the API. Describes 1.1.13+, including the named ZeroRangeCantBeReachedException / TrajectoryCannotBeCalculatedException failures.
BallisticCalculator
A .NET library (netstandard2.0) that models the trajectory of a projectile through the
atmosphere using a 3DOF (point-mass) model.
- NuGet:
BallisticCalculator(namespaceBallisticCalculator). This describes 1.1.13+; where behaviour changed, the version that changed it is named. - Depends on
Gehtsoft.Measurements(namespaceGehtsoft.Measurements) — every physical quantity is a strongly-typedMeasurement<TUnit>. - License: LGPL 2.1.
using BallisticCalculator;
using Gehtsoft.Measurements;
The workflow is always: (1) describe the ammunition, rifle, and atmosphere → (2) compute the
zero (sight) correction with CalculateZeroParameters and Apply it to the ShotParameters →
(3) call Calculate → (4) read the returned TrajectoryPoint[].
1. Measurements (the value type used everywhere)
Every dimensional value is a Measurement<TUnit> struct — a double value plus a unit. You pick
the unit at construction and again when you read it; metric and US units interoperate freely.
var d = new Measurement<DistanceUnit>(100, DistanceUnit.Yard); // value + unit
var d2 = new Measurement<DistanceUnit>("100yd"); // parse "<number><unit>"
double meters = d.In(DistanceUnit.Meter); // convert & read as double
double raw = d.Value; // value in its own unit (100)
DistanceUnit u = d.Unit; // DistanceUnit.Yard
var zero = Measurement<DistanceUnit>.ZERO; // 0
double mm = Measurement<DistanceUnit>.Convert(1, DistanceUnit.Inch, DistanceUnit.Meter); // static double->double
var d3 = 100.As(DistanceUnit.Yard); // extension sugar: (double|int).As(unit)
var d4 = DistanceUnit.Yard.New(100); // unit-first sugar: unit.New(value)
Operators: + - (same unit), * / by a scalar, and comparisons. Nullable Measurement<T>?
marks optional inputs. The three construction forms above (new, .As, .New) are equivalent —
use whichever reads best.
Unit enums (use the exact member names):
| Enum | Members |
|------|---------|
| DistanceUnit | Millimeter, Centimeter, Meter, Kilometer, Inch, Foot, Yard, Mile, NauticalMile, Line, RussianLine, Point, Pica |
| VelocityUnit | MetersPerSecond, KilometersPerHour, FeetPerSecond, MilesPerHour, Knot |
| AngularUnit | Radian, Degree, MOA, Mil, MRad, Thousand, InchesPer100Yards, CmPer100Meters, Percent, Turn, Gradian |
| WeightUnit | Grain, Ounce, Gram, Pound, Kilogram, Neuton, Dram, TroyOz, Tonne, USTonne, UKTonne |
| PressureUnit | Pascal, KiloPascal, Bar, Millibar, Atmosphere, TechincalAtmosphere, MillimetersOfMercury, InchesOfMercury, PoundsPerSquareInch, MillimetersOfWater |
| TemperatureUnit | Fahrenheit, Celsius, Kelvin, Rankin, Reaumur, Delisle |
| EnergyUnit | FootPound, Joule, BTU, HpH, Wh |
| DensityUnit | GramPerCubicCentimeter, KilogramPerCubicMeter, PoundsPerCubicInch, OuncesPerCubicFeet, PoundsPerCubicFoot |
(TechincalAtmosphere is spelled that way in the library.)
2. Describing the shot (inputs)
Ammunition
public Ammunition(
Measurement<WeightUnit> weight,
BallisticCoefficient ballisticCoefficient,
Measurement<VelocityUnit> muzzleVelocity,
Measurement<DistanceUnit>? bulletDiameter = null, // required only for spin drift
Measurement<DistanceUnit>? bulletLength = null) // required only for spin drift
Properties: Weight, BallisticCoefficient, MuzzleVelocity, BulletDiameter?, BulletLength?,
CustomTableFileName. double GetBallisticCoefficient() returns the effective BC.
BallisticCoefficient (struct) and drag tables
new BallisticCoefficient(0.325, DragTableId.G7) // a coefficient
new BallisticCoefficient(1.0, DragTableId.GC, BallisticCoefficientValueType.FormFactor)
new BallisticCoefficient("0.325G7") // parse text form
DragTableId:G1, G2, G5, G6, G7, G8, GI, GS, RA4(standard curves) andGC(custom — see §6).BallisticCoefficientValueType:Coefficient(a BC number) orFormFactor.- Text form:
"0.325G7", or"F1GC"(leadingF= form factor; last two chars = table id).
Sight, Rifling, ZeroingParameters, Rifle
new Sight(Measurement<DistanceUnit> sightHeight,
Measurement<AngularUnit> verticalClick,
Measurement<AngularUnit> horizontalClick) // clicks may be Measurement<AngularUnit>.ZERO
new Rifling(Measurement<DistanceUnit> riflingStep, TwistDirection direction) // step = distance per turn; TwistDirection.Left|Right
new ZeroingParameters(Measurement<DistanceUnit> distance, Ammunition ammunition, Atmosphere atmosphere)
// ammunition/atmosphere may be null; when set they override the shot's ammo/atmo FOR ZEROING ONLY.
// Optional property: VerticalOffset (Measurement<DistanceUnit>?, + is up) shifts the zero impact point.
new Rifle(Sight sight, ZeroingParameters zero, Rifling rifling = null) // rifling optional
Atmosphere (immutable; derived values computed in the constructor)
new Atmosphere() // sea level standard
new Atmosphere(Measurement<DistanceUnit> altitude,
Measurement<PressureUnit> pressure, // STATION pressure at that altitude
Measurement<TemperatureUnit> temperature,
double humidity) // humidity is a FRACTION 0..1, not %
new Atmosphere(Measurement<DistanceUnit> altitude,
Measurement<PressureUnit> pressure,
bool pressureAtSeaLevel, // true => pressure is sea-level-corrected
Measurement<TemperatureUnit> temperature,
double humidity)
Atmosphere.CreateICAOAtmosphere(Measurement<DistanceUnit> altitude, double humidity = 0)
Read-only properties: Altitude, Pressure, Temperature, Humidity, SoundVelocity, Density,
DensityAltitude (1.1.12 — the standard-atmosphere altitude matching this air's density, humidity included).
Static: Atmosphere.StandardDensity.
Density is computed from the resolved station Pressure, so it is consistent with what the engine uses (this
was wrong before 1.1.12 — up to 21% high at 5000 ft with pressureAtSeaLevel: true). DensityAltitude is
baselined on the ICAO sea-level density, not Atmosphere.StandardDensity — they differ by ~0.005% (~1.9 ft)
and are not interchangeable.
Wind
new Wind(Measurement<VelocityUnit> velocity,
Measurement<AngularUnit> direction,
Measurement<DistanceUnit>? maximumRange = null)
- Direction: 0° blows toward the target, 90° blows from the right (right-to-left across the path), 180° toward the shooter, 270°/−90° from the left.
Calculatetakes aWind[]. Each wind applies out to itsMaximumRange; the last one (or one with a null range) applies to the end. Sort the array by ascendingMaximumRange.
ShotParameters (output granularity + geometry)
var shot = new ShotParameters
{
Step = new Measurement<DistanceUnit>(100, DistanceUnit.Yard), // row spacing
MaximumDistance = new Measurement<DistanceUnit>(1000, DistanceUnit.Yard), // extent
ShotAngle = null, // Measurement<AngularUnit>? line-of-sight incline, + up / - down
CantAngle = null, // Measurement<AngularUnit>?
BarrelAzimuth = null, // Measurement<AngularUnit>?
Latitude = null, // Measurement<AngularUnit>? shooter latitude (Coriolis); optional
};
// The zero (sight) correction is REQUIRED but not set by hand — compute it and copy it in
// (see §3). Apply fills ZeroDropAdjustment / ZeroWindageAdjustment on the shot:
shot.Apply(calc.CalculateZeroParameters(ammo, atmosphere, rifle, rifle.Zero));
Apply sets the ZeroDropAdjustment / ZeroWindageAdjustment properties; the incline-adjusted
ShotDropAdjustment / ShotWindageAdjustment are also exposed. There is no longer a SightAngle
property.
3. Running the calculation
public class TrajectoryCalculator
{
// Compute the zero (sight) correction for the rifle's zero distance. `zero` is usually
// rifle.Zero. Pass dragTable only when the BC uses table GC. NOTE the argument order:
// atmosphere comes BEFORE rifle here (the opposite of Calculate).
ZeroCalculatedParameters CalculateZeroParameters(
Ammunition ammunition, Atmosphere atmosphere, Rifle rifle, ZeroingParameters zero,
ShotParameters shot = null, Wind[] wind = null, DragTable dragTable = null,
Measurement<DistanceUnit>? accuracy = null); // accuracy default 0.1 mm
TrajectoryPoint[] Calculate(Ammunition ammunition, Rifle rifle, Atmosphere atmosphere,
ShotParameters shot, Wind[] wind = null, DragTable dragTable = null);
Measurement<DistanceUnit> MaximumCalculationStepSize { get; set; } // default 10 cm
IntegrationMethod Integrator { get; set; } // Euler | MidpointRK2 (default)
static Measurement<DistanceUnit> MaximumDrop { get; } // 10000 ft — stop condition
static Measurement<VelocityUnit> MinimumVelocity { get; } // 50 ft/s — stop condition
}
// Returned by CalculateZeroParameters; copy it into the shot with ShotParameters.Apply.
public class ZeroCalculatedParameters
{
Measurement<AngularUnit> ZeroDropAdjustment { get; } // vertical sight correction
Measurement<AngularUnit>? ZeroWindageAdjustment { get; } // horizontal, when computed
}
Compute the zero correction first, Apply it to the ShotParameters, then call Calculate:
var zero = calc.CalculateZeroParameters(ammo, atmosphere, rifle, rifle.Zero);
shot.Apply(zero); // sets shot.ZeroDropAdjustment (+ windage) — REQUIRED before Calculate
var trajectory = calc.Calculate(ammo, rifle, atmosphere, shot);
ShotAngle, if set, is added to the barrel elevation inside Calculate.
The result array can be shorter than MaximumDistance/Step + 1: the run stops early if velocity
drops below 50 ft/s or drop exceeds 10000 ft. Iterate the returned array; don't assume its length.
Failures the engine names
Both calls throw rather than returning nonsense, and the two conditions a user can fix have their own exception types:
// namespace BallisticCalculator; both : InvalidOperationException
public class ZeroRangeCantBeReachedException : InvalidOperationException { } // CalculateZeroParameters
public class TrajectoryCannotBeCalculatedException : InvalidOperationException { } // Calculate
| Type | Raised when | What the user changes |
|---|---|---|
| ZeroRangeCantBeReachedException | the load cannot carry to the zero distance — a subsonic pistol load asked for a 1,000 yd zero | zero closer, more muzzle velocity, better BC |
| TrajectoryCannotBeCalculatedException | the numbers leave nothing to integrate — a zero or absurd BC, weight or muzzle velocity | fix the offending ammunition field |
Two rules follow:
- Catch them before any broader handler. They derive from
InvalidOperationException, so acatch (InvalidOperationException)earlier in the chain swallows them. - Treat them as input errors, not crashes. Show a plain message saying what to change; keep the stack
trace for exceptions the engine has not named, which are bugs worth reporting. The other throws are
ordinary argument faults and stay in that second category:
ArgumentExceptionfor a form factor with no bullet diameter (or a non-positive form factor/weight), andArgumentNullExceptionwhen the BC table isGCand nodragTablewas supplied.
Where a field can be checked up front — a non-positive BC, weight or muzzle velocity — validate instead of
catching. TrajectoryCannotBeCalculatedException is the net under that, not a substitute for it.
4. Reading the output (TrajectoryPoint, read-only)
| Property | Type | Meaning |
|----------|------|---------|
| Time | TimeSpan | Time of flight to this point. |
| Distance | Measurement<DistanceUnit> | Distance along the line of sight. |
| DistanceFlat | Measurement<DistanceUnit> | Horizontal distance from the muzzle. |
| Velocity | Measurement<VelocityUnit> | Projectile speed. |
| Mach | double | Speed relative to the local speed of sound. |
| Drop | Measurement<DistanceUnit> | Vertical position vs the line of sight. At the muzzle = −sight height. |
| DropFlat | Measurement<DistanceUnit> | Vertical position vs the muzzle (bore line). |
| Windage | Measurement<DistanceUnit> | Horizontal deflection. Left +, right −. Includes spin drift when modelled. |
| Energy | Measurement<EnergyUnit> | Kinetic energy. |
| DropAdjustment | Measurement<AngularUnit> | Angular scope correction for the drop at this distance. |
| WindageAdjustment | Measurement<AngularUnit> | Angular correction for the windage at this distance. |
| LineOfSightElevation | Measurement<DistanceUnit> | Height of the sight line at this distance. |
| LineOfDepartureElevation | Measurement<DistanceUnit> | Height of the bore line at this distance. |
| OptimalGameWeight | Measurement<WeightUnit> | Litz optimal game weight estimate. |
foreach (var p in trajectory)
Console.WriteLine($"{p.Distance.In(DistanceUnit.Yard):N0} yd " +
$"{p.Velocity.In(VelocityUnit.FeetPerSecond):N0} fps " +
$"drop {p.Drop.In(DistanceUnit.Inch):N1} in " +
$"wind {p.Windage.In(DistanceUnit.Inch):N1} in");
5. Complete examples
Minimal
var ammo = new Ammunition(
weight: new Measurement<WeightUnit>(168, WeightUnit.Grain),
ballisticCoefficient: new BallisticCoefficient(0.223, DragTableId.G7),
muzzleVelocity: new Measurement<VelocityUnit>(2700, VelocityUnit.FeetPerSecond));
var rifle = new Rifle(
sight: new Sight(new Measurement<DistanceUnit>(1.5, DistanceUnit.Inch),
Measurement<AngularUnit>.ZERO, Measurement<AngularUnit>.ZERO),
zero: new ZeroingParameters(new Measurement<DistanceUnit>(100, DistanceUnit.Yard), null, null));
var atmosphere = new Atmosphere();
var calc = new TrajectoryCalculator();
var shot = new ShotParameters
{
MaximumDistance = new Measurement<DistanceUnit>(1000, DistanceUnit.Yard),
Step = new Measurement<DistanceUnit>(100, DistanceUnit.Yard),
};
shot.Apply(calc.CalculateZeroParameters(ammo, atmosphere, rifle, rifle.Zero));
TrajectoryPoint[] trajectory = calc.Calculate(ammo, rifle, atmosphere, shot);
Non-standard atmosphere + wind + spin drift
Spin drift is modelled only when the rifle has Rifling and the ammunition has both a bullet
diameter and length; it is folded into Windage (there is no separate output).
var ammo = new Ammunition(
weight: new Measurement<WeightUnit>(168, WeightUnit.Grain),
ballisticCoefficient: new BallisticCoefficient(0.223, DragTableId.G7),
muzzleVelocity: new Measurement<VelocityUnit>(2700, VelocityUnit.FeetPerSecond),
bulletDiameter: new Measurement<DistanceUnit>(0.308, DistanceUnit.Inch),
bulletLength: new Measurement<DistanceUnit>(1.2, DistanceUnit.Inch));
var rifle = new Rifle(
sight: new Sight(new Measurement<DistanceUnit>(2.0, DistanceUnit.Inch),
Measurement<AngularUnit>.ZERO, Measurement<AngularUnit>.ZERO),
zero: new ZeroingParameters(new Measurement<DistanceUnit>(100, DistanceUnit.Yard), null, null),
rifling: new Rifling(new Measurement<DistanceUnit>(11.25, DistanceUnit.Inch), TwistDirection.Right));
var atmosphere = new Atmosphere(
altitude: new Measurement<DistanceUnit>(5000, DistanceUnit.Foot),
pressure: new Measurement<PressureUnit>(24.9, PressureUnit.InchesOfMercury),
temperature: new Measurement<TemperatureUnit>(40, TemperatureUnit.Fahrenheit),
humidity: 0.30);
var wind = new[]
{
new Wind(new Measurement<VelocityUnit>(10, VelocityUnit.MilesPerHour),
new Measurement<AngularUnit>(90, AngularUnit.Degree),
new Measurement<DistanceUnit>(500, DistanceUnit.Yard)), // out to 500 yd
new Wind(new Measurement<VelocityUnit>(5, VelocityUnit.MilesPerHour),
new Measurement<AngularUnit>(45, AngularUnit.Degree)), // beyond 500 yd
};
var calc = new TrajectoryCalculator();
var shot = new ShotParameters
{
MaximumDistance = new Measurement<DistanceUnit>(1000, DistanceUnit.Yard),
Step = new Measurement<DistanceUnit>(100, DistanceUnit.Yard),
};
shot.Apply(calc.CalculateZeroParameters(ammo, atmosphere, rifle, rifle.Zero));
var trajectory = calc.Calculate(ammo, rifle, atmosphere, shot, wind);
6. Custom drag curves
Standard G1..RA4 curves are applied automatically when the BC names a standard table — nothing
extra to do. If, and only if, the task involves a non-standard drag curve — a custom/measured
drag table, a radar .drg file, or a multi-BC (BC-vs-Mach) profile — read
references/custom-drag.md and follow it. That file has the full,
copy-pasteable recipes for all three (DragTable subclass, DrgDragTable.Open, and
DrgDragTableFactory.Build with BcAtMach), including the exact signatures. Do not reconstruct
this API from memory or hand-roll a drag table — the helpers exist and the reference has them.
All three techniques use table id GC and require passing the DragTable instance to both
CalculateZeroParameters and Calculate (DragTable.Get(GC) throws — you supply the instance).
7. Conventions & gotchas
- Humidity is a fraction 0..1, not a percentage (0.5 = 50 %).
- Pressure in the 4-argument
Atmosphereconstructor is the station pressure at that altitude; use the 5-argument overload withpressureAtSeaLevel: trueto pass a sea-level value. - The zero correction must be applied before
Calculate: compute it withCalculateZeroParametersand copy it into the shot viaShotParameters.Apply(...). There is noSightAngleproperty anymore. - Argument order differs between the two calls:
CalculateZeroParameters(ammunition, atmosphere, rifle, zero)puts atmosphere before rifle, whereasCalculate(ammunition, rifle, atmosphere, shot)puts rifle before atmosphere. Easy to transpose. - Windage sign: left is positive, right is negative.
Dropat the muzzle = −sight height. - Spin drift needs
RiflingandBulletDiameterandBulletLength; otherwise windage is wind-only. It is folded intoWindage(no separate value). Right twist drifts right, left drifts left. GC(custom) drag requires passing theDragTableto bothCalculateZeroParametersandCalculate;DragTable.Get(DragTableId.GC)throws — you must supply the instance.- The returned array may contain fewer rows than requested (subsonic/steep runs stop early).
ZeroRangeCantBeReachedException/TrajectoryCannotBeCalculatedExceptionboth derive fromInvalidOperationException— catch them before any broader handler, and treat them as input errors the user can fix rather than crashes. See §3.AngularUnit.Milis the military mil (1/6400), not the milliradian (MRad, 1/6283.19) — ~1.9 % apart. A mil-dot reticle is a milliradian instrument, andMilDotReticleis built inMRad.- Crosswind aerodynamic jump IS modelled (Litz, Applied Ballistics Eq 5.4), so a pure crosswind moves the
point of impact vertically as well as horizontally. Like spin drift it needs
Riflingand the bullet diameter and length; without them the term is absent. Right twist + wind from the right lifts the impact.
8. Specialized topics (reference files)
These live in references/ and load only when the task needs them — read the matching file and follow
it rather than reconstructing the API from memory:
- Custom / non-standard drag curves (custom
DragTable, radar.drg, multi-BC synthesis) →references/custom-drag.md(see §6). - Serialization & persistence — saving/loading
Ammunition/Rifle/Atmosphere/libraries via BXml orSystem.Text.Json, embedding library objects in your own file format, and decorating your own classes for the BXml serializer →references/serialization.md. - Reticles — building a reticle definition in code and rendering it (e.g. to SVG), including
bullet-drop-compensator markers →
references/reticle.md.
Next.js App Router Expert
Development
A skill that turns Claude into a Next.js App Router expert.
README Generator
Development
Creates professional and comprehensive README.md files for your projects.
API Documentation Writer
Development
Generates comprehensive API documentation in OpenAPI/Swagger format.