-
Notifications
You must be signed in to change notification settings - Fork 0
Theory
This document derives the physics and numerics actually implemented in
pibs/ballistics/, so you can read the code (or trust its output) with
a clear picture of what it's computing. It follows the "Serebryakov
system" of reduced-variable interior ballistics, as used in Soviet/PRC
ballistics literature (see References).
Everything here is a direct read of base_gun.py, gun.py, recoilless.py
and prop/prop.py — not a black-box description.
Interior ballistics covers the phase from primer ignition to shot exit: propellant burns, gas pressure builds, the shot accelerates down the bore. PIBS solves the forward problem (given a design, compute its pressure/velocity trace, §1-§10) and the inverse/constrained problem (given performance targets, solve for the grain web size and barrel length, §11) — both available in either GUI.
The model is 0-dimensional in the combustion chamber (space-mean pressure) with a 1-dimensional correction for the pressure gradient along the bore (§6). It is not a CFD simulation — there's no radial structure, no turbulence, no ignition transient. That's the right trade-off for design-level accuracy in seconds rather than hours.
Rather than integrate pressure, travel, velocity and time in SI units
directly, the solver non-dimensionalizes everything against
charge/gun-specific scales. This is what "reduced form" means in the
README, and it's why gun.py variable names look like p_bar,
l_bar, v_bar, t_bar — _bar denotes the dimensionless version.
| Quantity | Scale | Reduced variable |
|---|---|---|
| Pressure | p_scale = f·Δ |
p_bar = p / p_scale |
| Travel |
l_0 = V_0 / S (chamber volume ÷ bore area — "equivalent chamber length") |
l_bar = l / l_0 |
| Velocity | v_j = √(2 f w / (θ φ m)) |
v_bar = v / v_j |
| Time | t_scale = l_0 / v_j |
t_bar = t / t_scale |
| Burnt fraction | web-normalized | Z = e / e_1 ∈ [0, Z_b] |
where:
-
f— propellant force (specific energy of combustion, J/kg) -
Δ = w / V_0— loading density (charge mass / chamber volume) -
w— charge mass,m— shot mass -
θ— reduced adiabatic index of propellant gas (θ = γ − 1) -
φ— the fictitious mass factor (§5): shot mass alone under-accounts for how much mass the pressure has to accelerate, because the propellant gas itself has inertia. -
e_1— grain half-web (half-thickness of the burning layer);Z=1is when the primary grain shape burns through,Z=Z_b ≥ 1is complete combustion (see §3 for multi-perforated grains).
v_j is the theoretical maximum velocity the charge could impart to
the shot in the idealized limit of complete, instantaneous burning
with no losses — it's the natural velocity scale for the problem.
As a grain burns, its burning surface area changes with the fraction of web consumed — this is the "form function". PIBS uses the standard cubic form function:
ψ(Z) = χ·Z·(1 + λ·Z + μ·Z²) for 0 ≤ Z ≤ 1
ψ(Z) = χₛ·Z·(1 + λₛ·Z) for 1 < Z ≤ Z_b (secondary/sliver phase)
σ(Z) = dψ/dZ (relative burning surface)
ψ(Z) is the burnt mass fraction; σ(Z) its derivative, i.e. the
burning-surface area relative to the initial grain surface. The
coefficients (χ, λ, μ) — and (χₛ, λₛ, Z_b) for grains that keep
burning past Z=1 — are not free parameters; they're derived
from grain geometry:
-
Simple geometries (
SimpleGeometryinprop/prop.py): sphere, cylinder, tube, strip. Two shape ratiosα = e₁/b,β = e₁/c(web vs. the grain's other two characteristic dimensions) fully determine(χ, λ, μ); these grains burn out atZ_b = 1(no secondary phase — a sphere just gets smaller and vanishes). -
Multi-perforated geometries (
MultPerfGeometry): 7/14/19-perf cylinders/rosettes/hexagons. These start out progressive (burning surface increases as the perforations enlarge) until the web between perforations is consumed (Z=1), after which the grain fragments into slivers that burn out degressively for1 < Z ≤ Z_b. This progressive-then-degressive shape is why multi-perf propellant is standard in high-performance guns — it front-loads surface area growth to partially offset the falling pressure as chamber volume increases with shot travel.
A Propellant can mix two grain populations (main + auxiliary, with
independently set geometry, size and web ratio) — used for igniter/
auxiliary charges in some of the bundled examples — plus an optional
combustible-cartridge fraction (case material that itself burns and
contributes gas).
Grains regress at a rate governed by Vieli's/Muraour's law — linear in pressure raised to a (propellant-specific) exponent:
dZ/dt = u₁·p^n / e₁
u₁ (burn rate coefficient) and n (pressure exponent) come from the
propellant composition (propellants.csv — 40 bundled
compositions sourced from published references), separate from
geometry (grain shape) — the two combine multiplicatively in the
Propellant class (composition × geometry = grain).
Propellant gas is modeled with the Nobel–Abel (covolume) equation of
state — ideal gas with a correction α for the finite volume the gas
molecules themselves occupy at high density. Combining this with
conservation of energy (chemical energy released by burning = kinetic
energy of shot + gas + work done against bore friction and ambient
counter-pressure) yields the space-mean pressure as an algebraic
function of the current state, without needing to integrate a
separate energy equation:
p_bar = (ψ − v_bar²) / (l_bar + l_ψ_bar)
l_ψ_bar = 1 − Δ·[(1−ψ)/ρ_p + α·ψ]
l_ψ_bar is the "unburnt-propellant and covolume" correction to the
available expansion length — it accounts for the volume still occupied
by solid, unburnt propellant ((1−ψ)/ρ_p) and the covolume of the gas
already produced (α·ψ). This is Gun.f_p_bar() in the code, and
it's evaluated fresh at every integration step — it is not itself an
ODE state variable.
With p_bar available algebraically, the reduced-variable ODE system
(time domain) is:
dZ/dt_bar = √(θ/2b) · p_bar^n (burn progress)
dl_bar/dt_bar = v_bar (kinematics)
dv_bar/dt_bar = (θ/2)·(p_bar − p_ad_bar(v_bar)) (Newton's 2nd law, reduced)
b is a dimensionless grouping of the burn-rate/geometry/energy terms:
b = S²e₁² / (f·φ·w·m·u₁²) · (f·Δ)^(2(1−n))
(S = bore area). p_ad_bar(v_bar) is the ambient aerodynamic
counter-pressure (§7).
gun.py actually solves this system in three different independent
variables depending on which is most numerically convenient at each
stage: ode_t (time), ode_l (travel — needed once v_bar is no
longer small, since 1/v_bar blows up at t=0), and ode_z (burnt
fraction — used to robustly locate burnout/exit even when dZ/dt→0
near full burn). This is why the source has three near-identical ode_*
methods; they're the same physics, re-parameterized for numerical
robustness at different phases of the shot.
Newton's law needs some mass to divide by, but only the shot's mass is truly rigid — the propellant gas behind it also accelerates and carries kinetic energy. Rather than track the gas velocity field explicitly, PIBS lumps this into a correction factor:
φ = φ₁ + λ₂·cc·(w/m)
φ₁ = 1/(1 − drag_coefficient) is the separate correction for bore
friction/engraving resistance (the "Resistance %" input). λ₂ and the
chambrage correction cc come from the pressure-gradient solution,
§6 — this is where the charge-to-shot mass ratio and gun geometry feed
back into the acceleration itself, not just the pressure distribution.
Space-mean pressure (§4) is a single number, but at any instant the local pressure is highest at the breech (accelerating the gas column behind it) and lowest at the shot base — this is the classical "Lagrange problem" of interior ballistics: gas of nonzero mass filling a tube, accelerated by pressure with a fixed shot at one end.
PIBS solves this via two dimensionless distribution coefficients
λ₁, λ₂, computed once per gun from one of three closed-form/
semi-closed-form models (the "Gradient" setting, Solutions in
ballistics/__init__.py):
-
SOL_LAGRANGE: the classical simplifying assumption — gas density uniform along the tube at every instant. Gives the closed formλ₁ = 1/2, λ₂ = 1/3directly, no iteration needed. -
SOL_PIDDUCK: Pidduck's self-similar solution for a gas with adiabatic indexθ+1, solved via the integral equation∫₀¹ (1 − Ω·ξ²)^[1/(k−1)] dξ = [w/(2φ₁m)]·[(k−1)/k]·(1−Ω)^[k/(k−1)] / Ωfor
Ω(root-found with the Dekker method) —w/(φ₁m)is the charge mass relative to the friction-corrected ("fictitious") shot mass, not the raw shot mass.λ₁, λ₂follow fromΩby further quadrature. This is the more physically complete model and is the default. -
SOL_MAMONTOV: thek→1(isothermal-gas) limit of the same Pidduck family, evaluated as a separate closed form to avoid the numerical0/0atk=1.
(pidduck() in gun.py implements the shared machinery for the
latter two; see its docstring for the exact source citation.)
Given λ₁, λ₂ and a chambrage correction cc (which accounts for
the chamber being wider than the bore, χ_k = chamber area / bore area), the breech and shot-base pressures are recovered from the
space-mean pressure:
P_s / P = 1 / (1 + λ₂'·w/(φ₁·m))
P_b / P = (φ₁·m + λ₂'·w) / (φ₁·m + λ₁'·w)
with λ₁', λ₂' further adjusted for how far the shot has travelled
(the chamber's relative contribution to the total gas column shrinks
as travel increases). Pressure at an arbitrary axial probe point
between breech and shot is then interpolated assuming a quadratic
(Lagrangian) profile in the gas velocity field — this is what powers
the "Barrel Trace" plot in the desktop app and the tube-strength
calculation.
This is why "Peak Avg Pressure", "Peak Breech Pressure" and "Peak Shot Pressure" are three different numbers occurring at three different times — each is found by an independent Golden Section Search over the relevant pressure trace (§8), not read off the same peak.
Once the propellant is exhausted (or even before, for high-velocity
shots), the atmosphere in front of the shot pushes back. This is
modeled as a counter-pressure term derived from the shot's velocity
relative to the local speed of sound, using the ambient density,
pressure and adiabatic index inputs (func_p_ad_bar() in
base_gun.py) — a standard supersonic-projectile drag correction. It
can be switched off (setting ambient density to zero), in which case
this term vanishes and the gun is solved as if firing in vacuum ahead
of the shot.
The solver doesn't just uniformly sample the trajectory — it explicitly locates named events, each via the appropriate numerical technique:
| Event | Meaning | Method |
|---|---|---|
SHOT_START |
ignition, Z=Z₀ (see below) |
initial condition |
FRACTURE |
Z=1, primary grain shape consumed (multi-perf only) |
direct integration to Z=1
|
BURNOUT |
Z=Z_b, propellant fully consumed |
direct integration to Z=Z_b
|
SHOT_EXIT |
l = l_g (barrel length reached) |
direct integration to l_bar=l_g_bar
|
PEAK_AVG_P / PEAK_BREECH_P / PEAK_SHOT_P
|
maxima of the respective pressure trace vs. time |
Golden Section Search (gss()), independently per pressure definition |
Z₀ (the burnt fraction at ignition) is itself root-found: given a
user-specified start pressure (the pressure needed to shear the
shot's rotating band / begin motion), PIBS solves ψ(Z₀) = ψ₀ where
ψ₀ comes from inverting the Nobel-Abel EOS at that pressure and zero
travel — via the Dekker method (dekker(), a bisection/secant/
inverse-quadratic hybrid similar to Brent's method).
The ODE system itself is integrated with an adaptive embedded
Runge–Kutta–Fehlberg scheme (rkf()), which self-adjusts step
size to a user-specified relative tolerance (-log10(ε) in the UI —
e.g. a value of 4 means tolerance 1e-4) and supports early-abort
conditions (used to detect pressure exceeding the 600 MPa validity
ceiling of the Nobel-Abel EOS, or a squib condition where the shot
stalls in the bore).
A recoilless gun vents propellant gas backward through a nozzle,
countering the breech reaction so the weapon needs no recoil
mechanism. Recoilless (in recoilless.py) extends the conventional
model with two additional coupled state variables tracked through
every integration step:
-
η(eta) — cumulative mass fraction of propellant gas that has flowed out through the nozzle (as opposed to still filling the chamber/bore behind the shot) -
τ(tau) — a reduced temperature/energy ratio, since gas leaving through the nozzle carries away energy, which the simple closed-form pressure equation (§4) no longer captures on its own
The space-mean pressure becomes p_bar = τ/(l_bar+l_ψ_bar)·(ψ−η)
(compare to §4 — note the (ψ−η) in place of ψ: only the gas mass
retained in the gun contributes to pressure) and dη, dτ are
integrated alongside Z, l_bar, v_bar.
The recoilless condition — zero net reaction force — is enforced
by sizing the throat area, not by an extra force-balance ODE. At
construction time, Recoilless.__init__ computes the required
dimensionless throat area s_j_bar from the nozzle expansion ratio
(a_bar) and nozzle efficiency (χ₀, an empirical loss factor for
non-ideal nozzle flow) via the standard isentropic converging-
diverging nozzle thrust-coefficient relation, then checks it physically
fits within the breech face (s_j_bar ≤ chambrage ratio) — raising a
clear error if it doesn't (an infeasible design, not a numerical
failure).
Because a meaningful fraction of the propellant's chemical energy is deliberately vented rather than used to accelerate the shot, recoilless guns have markedly lower thermal/ballistic efficiency than closed-breech guns for the same propellant — confirmed by the bundled examples: the 105mm M40A1 recoilless design solves to ~7% thermal efficiency vs. ~33% for the 76mm ZiS-3 conventional gun (see the User Guide for the full comparison). This is the expected physical trade-off for eliminating recoil, not a modeling artifact.
Three efficiency figures are derived from the solved trace
(GenericResult.get_eff()):
thermal efficiency te = (v_muzzle / v_j)²
ballistic efficiency be = te / φ
piezometric efficiency pe = ½·φ·m·v_muzzle² / (P_max·S·l_g)
te measures how much of the theoretical maximum velocity (§2) was
actually realized. be further corrects for the fictitious-mass
factor φ, isolating losses attributable to charge-to-shot mass ratio
and pressure gradient rather than incomplete combustion. pe measures
how efficiently peak pressure was "used" — a gun whose pressure trace
is flat and high for the whole travel (ideal) has pe→1; one with a
sharp early spike that decays quickly wastes barrel length at
sub-peak pressure and has low pe.
Everything above solves the forward problem: given a design, compute its performance. The pieces below build on that same core solver to solve the inverse problem (given performance targets, find a design) and to size the resulting barrel. All four are available in both the desktop app and the web GUI.
ConstrainedGun/ConstrainedRecoilless (constrained_gun.py,
constrained_recoilless.py) solve for grain web size and barrel
length given a target velocity v_d and pressure p_d, via nested
root-finding:
- For a chosen charge-to-shot mass ratio
w/mand chamber loading densityΔ/ρ_p("load fraction"), the charge mass and chamber volume — and hencel_0,Δ— are fixed. - The web (via half-web
e_1) is root-found with the Dekker method so that the chosen pressure constraint point (space-mean, breech, or shot-base — the "Pressure Constraint Point") exactly peaks at the design pressurep_d: the search probes outward from a small starting web, doubling/halving until the sign ofp_peak(e_1) − p_dbrackets a root, then converges on it. - With
e_1fixed, the gun is integrated forward from that peak point until velocity reachesv_d, giving the required barrel lengthl_g. - Because the chambrage correction
cc(§6) depends onl_g, and the pressure-gradient coefficients used in step 2 depend oncc, steps 2-3 repeat with the newly-convergedccuntill_gitself stops changing (bounded bymax_iteration).
An error here ("Solution requires excessive tube length...", "Design velocity exceeded before peak pressure point...") means the chosen charge ratio and loading density can't physically reach the target within the barrel-length search ceiling — not a numerical failure.
Constrained.find_min_v() treats load fraction as a free variable
instead of a fixed input: it searches it with Golden Section
Search between the minimum load fraction (below which the design
pressure is unreachable even in the closed-bomb limit — psi_0 would
exceed 1, §8) and the maximum (found by maximum_load_fraction(),
which probes upward until the constrained solve in §11.1 stops
converging), minimizing either total chamber+barrel volume
(MIN_BARR_VOLUME) or barrel length alone (MIN_PROJ_TRAVEL).
Given the solved pressure trace, Gun.structure() sizes barrel wall
thickness at each axial probe point using thick-walled cylinder
theory under the Tresca (maximum shear stress) yield criterion,
against the highest pressure ever reached at that axial position
across the entire trace (not just when the shot happens to pass it —
the pressure envelope over time, via to_px_u() from §6), scaled by
a safety factor.
Monoblock (single-piece barrel) — for inner:outer radius ratio k:
k = (1 − 2p/σ_y)^(−1/2)
Thickening the wall (raising k) lets it contain a higher p without
the bore exceeding yield strength σ_y — but this construction is
mathematically impossible above p = σ_y/2, regardless of wall
thickness (the bore itself yields under a purely elastic stress
distribution before the wall can help).
Autofrettage — the barrel is first pressurized beyond yield so it retains a residual compressive pre-stress at the bore once depressurized; the working pressure must first overcome that pre-stress before the bore sees any net tension. The optimal autofrettage radius ratio:
m = exp(p / σ_y), k = m (full autofrettage)
has no p = σ_y/2 ceiling — which is why high-pressure guns are
routinely autofrettaged rather than built monoblock. Barrel volume
(and hence estimated tube mass) is integrated from these radius ratios
along the bore.
A guidance diagram maps every design — across a grid of charge-to-shot
mass ratios and chamber loading densities — that satisfies a given
velocity/pressure target, showing how barrel length and chamber
volume trade off across that space. It's §11.1's constrained solve
repeated at every grid point: for each charge ratio, the maximum
feasible load fraction is found (maximum_load_fraction()), then
every load fraction from the minimum up to that maximum (in steps) is
solved individually, keeping only the combinations that converge to a
physically valid design.
The desktop app parallelizes this sweep across CPU cores
(multiprocessing.Pool, since a single diagram can mean solving
hundreds of individual designs). The web GUI instead runs it
sequentially (guide_graph(sequential=True, ...)) — spawning worker
processes from inside a Streamlit rerun is unsafe on Windows' spawn
start method, since each worker would try to re-import and re-run the
Streamlit script itself — so keep the sweep grid modest there.
As cited in the source (docstrings, pidduck() in gun.py, and the
bundled example descriptions):
- 金志明 (2014). 《枪炮内弹道学》(Interior Ballistics of Guns).
- 鲍廷钰,邱文坚 (1995). 《内弹道学》(Interior Ballistics).
- 王连荣,张佩勤 (1987). 《火炮内弹道计算手册》(Gun Interior Ballistics Calculation Handbook), National Defense Industry Press.
- 兵器工业部第二管理局 (1982). 《国产火炮手册》(Domestic Gun Handbook).
- Multiple Russian-language ordnance service manuals (cited per-example in
pibs/examples/*.json).
- Scope
- Reduced variables
- Charge model
- Equation of state
- Equation of motion
- Pressure gradient
- Aerodynamic drag
- Numerics
- Recoilless guns
- Efficiencies
- Constrained design & structural sizing
- References
- Which interface
- Install
- Run
- Worked: conventional
- Worked: recoilless
- Manual input
- Constrained design
- Tube strength
- Guidance diagram
- Reading output
- Full example run
- Troubleshooting
- Next steps