import struct
import sys
import requests
from pathlib import Path
API_URL = "http://127.0.0.1:5001"
BC_SEGMENTS_ENDPOINT = "/v1/bc_segments/estimate"
TABLE_DIR = Path(__file__).parent.parent / "data/bc_tables"
TEST_CASES = [
(0.224, 55, 0.243, 3200, "G1"),
(0.224, 77, 0.372, 2750, "G1"),
(0.243, 95, 0.430, 3000, "G1"),
(0.243, 105, 0.530, 2900, "G7"),
(0.264, 140, 0.625, 2900, "G1"),
(0.264, 147, 0.697, 2700, "G7"),
(0.277, 130, 0.450, 3050, "G1"),
(0.277, 150, 0.574, 2850, "G1"),
(0.284, 162, 0.625, 2900, "G1"),
(0.284, 175, 0.672, 2800, "G7"),
(0.308, 168, 0.462, 2700, "G1"),
(0.308, 175, 0.505, 2800, "G1"),
(0.308, 190, 0.533, 2650, "G7"),
(0.338, 250, 0.670, 2800, "G1"),
(0.338, 300, 0.818, 2650, "G7"),
]
VELOCITY_POINTS = [3000, 2500, 2000, 1500, 1200, 1000, 800]
def load_bc5d_table(caliber: float) -> dict:
caliber_int = int(caliber * 1000)
table_path = TABLE_DIR / f"bc5d_{caliber_int}.bin"
if not table_path.exists():
return None
with open(table_path, "rb") as f:
magic = f.read(4)
if magic != b"BC5D":
return None
version = struct.unpack("<I", f.read(4))[0]
cal = struct.unpack("<f", f.read(4))[0]
flags = struct.unpack("<I", f.read(4))[0]
padding = struct.unpack("<I", f.read(4))[0]
dim_weight = struct.unpack("<I", f.read(4))[0]
dim_bc = struct.unpack("<I", f.read(4))[0]
dim_muzzle = struct.unpack("<I", f.read(4))[0]
dim_current = struct.unpack("<I", f.read(4))[0]
dim_drag = struct.unpack("<I", f.read(4))[0]
timestamp = struct.unpack("<Q", f.read(8))[0]
checksum = struct.unpack("<I", f.read(4))[0]
api_version = f.read(16).rstrip(b"\x00").decode()
reserved = f.read(12)
weight_bins = [struct.unpack("<f", f.read(4))[0] for _ in range(dim_weight)]
bc_bins = [struct.unpack("<f", f.read(4))[0] for _ in range(dim_bc)]
muzzle_bins = [struct.unpack("<f", f.read(4))[0] for _ in range(dim_muzzle)]
current_bins = [struct.unpack("<f", f.read(4))[0] for _ in range(dim_current)]
total = dim_drag * dim_weight * dim_bc * dim_muzzle * dim_current
data = [struct.unpack("<f", f.read(4))[0] for _ in range(total)]
return {
"weight_bins": weight_bins,
"bc_bins": bc_bins,
"muzzle_bins": muzzle_bins,
"current_bins": current_bins,
"dim_drag": dim_drag,
"data": data,
}
def interp_idx(value: float, bins: list) -> tuple:
if value <= bins[0]:
return 0, 0.0
if value >= bins[-1]:
return len(bins) - 2, 1.0
for i in range(len(bins) - 1):
if bins[i] <= value <= bins[i + 1]:
w = (value - bins[i]) / (bins[i + 1] - bins[i])
return i, w
return len(bins) - 2, 1.0
def lookup_bc5d(table: dict, weight: float, bc: float, muzzle_vel: float, current_vel: float, drag_type: str) -> float:
drag_idx = 1 if drag_type.upper() == "G7" else 0
wi, ww = interp_idx(weight, table["weight_bins"])
bi, bw = interp_idx(bc, table["bc_bins"])
mi, mw = interp_idx(muzzle_vel, table["muzzle_bins"])
ci, cw = interp_idx(current_vel, table["current_bins"])
n_weight = len(table["weight_bins"])
n_bc = len(table["bc_bins"])
n_muzzle = len(table["muzzle_bins"])
n_current = len(table["current_bins"])
def flat_idx(d, w, b, m, c):
return (d * n_weight * n_bc * n_muzzle * n_current +
w * n_bc * n_muzzle * n_current +
b * n_muzzle * n_current +
m * n_current +
c)
result = 0.0
for dw in [0, 1]:
for db in [0, 1]:
for dm in [0, 1]:
for dc in [0, 1]:
weight_factor = (1 - ww) if dw == 0 else ww
bc_factor = (1 - bw) if db == 0 else bw
muzzle_factor = (1 - mw) if dm == 0 else mw
current_factor = (1 - cw) if dc == 0 else cw
w_idx = min(wi + dw, n_weight - 1)
b_idx = min(bi + db, n_bc - 1)
m_idx = min(mi + dm, n_muzzle - 1)
c_idx = min(ci + dc, n_current - 1)
idx = flat_idx(drag_idx, w_idx, b_idx, m_idx, c_idx)
result += weight_factor * bc_factor * muzzle_factor * current_factor * table["data"][idx]
return max(0.5, min(1.5, result))
def query_api_bc(caliber: float, weight: float, bc: float, muzzle_vel: float, drag_type: str) -> list:
payload = {
"manufacturer": "Generic",
"model": f"{weight:.0f}gr Match",
"caliber": caliber,
"weight": weight,
"bc_value": bc,
"bc_type": drag_type,
"muzzle_velocity": muzzle_vel,
"bullet_type": "match",
}
try:
resp = requests.post(f"{API_URL}{BC_SEGMENTS_ENDPOINT}", json=payload, timeout=10)
if resp.status_code == 200:
return resp.json().get("segments", [])
except Exception as e:
print(f"API error: {e}")
return []
def get_api_bc_at_velocity(segments: list, velocity: float) -> float:
if not segments:
return None
for seg in sorted(segments, key=lambda s: s["velocity_min"]):
if seg["velocity_min"] <= velocity <= seg["velocity_max"]:
return seg["bc_value"]
if velocity < segments[0]["velocity_min"]:
return min(segments, key=lambda s: s["velocity_min"])["bc_value"]
return max(segments, key=lambda s: s["velocity_max"])["bc_value"]
def main():
print("=" * 80)
print("BC5D Offline vs Online API Benchmark")
print("=" * 80)
print()
total_comparisons = 0
total_error = 0.0
max_error = 0.0
max_error_case = None
errors_above_1pct = 0
errors_above_2pct = 0
errors_above_5pct = 0
tables = {}
for caliber, weight, bc, muzzle_vel, drag_type in TEST_CASES:
if caliber not in tables:
tables[caliber] = load_bc5d_table(caliber)
if tables[caliber] is None:
print(f"Warning: No table for caliber {caliber}")
continue
table = tables[caliber]
if table is None:
continue
segments = query_api_bc(caliber, weight, bc, muzzle_vel, drag_type)
if not segments:
print(f"Warning: No API response for {caliber}/{weight}gr/{bc}/{drag_type}")
continue
print(f"\n{caliber} cal, {weight}gr, BC={bc} {drag_type}, MV={muzzle_vel}fps:")
print(f" {'Velocity':>8} {'API BC':>10} {'Table BC':>10} {'Diff':>8} {'Error%':>8}")
print(f" {'-'*8} {'-'*10} {'-'*10} {'-'*8} {'-'*8}")
for vel in VELOCITY_POINTS:
if vel > muzzle_vel:
continue
api_bc = get_api_bc_at_velocity(segments, vel)
if api_bc is None:
continue
correction = lookup_bc5d(table, weight, bc, muzzle_vel, vel, drag_type)
table_bc = bc * correction
diff = table_bc - api_bc
error_pct = abs(diff / api_bc) * 100 if api_bc > 0 else 0
total_comparisons += 1
total_error += error_pct
if error_pct > max_error:
max_error = error_pct
max_error_case = (caliber, weight, bc, drag_type, muzzle_vel, vel, api_bc, table_bc)
if error_pct > 1:
errors_above_1pct += 1
if error_pct > 2:
errors_above_2pct += 1
if error_pct > 5:
errors_above_5pct += 1
status = "OK" if error_pct < 2 else "!!" if error_pct < 5 else "XXX"
print(f" {vel:>8} {api_bc:>10.5f} {table_bc:>10.5f} {diff:>+8.5f} {error_pct:>7.2f}% {status}")
print()
print("=" * 80)
print("SUMMARY")
print("=" * 80)
print(f"Total comparisons: {total_comparisons}")
print(f"Average error: {total_error / total_comparisons:.3f}%" if total_comparisons > 0 else "N/A")
print(f"Max error: {max_error:.3f}%")
if max_error_case:
cal, wt, bc, dt, mv, vel, api, tbl = max_error_case
print(f" at: {cal} cal, {wt}gr, BC={bc} {dt}, MV={mv}, vel={vel}")
print(f" API={api:.5f}, Table={tbl:.5f}")
print()
print(f"Errors > 1%: {errors_above_1pct} ({100*errors_above_1pct/total_comparisons:.1f}%)" if total_comparisons > 0 else "N/A")
print(f"Errors > 2%: {errors_above_2pct} ({100*errors_above_2pct/total_comparisons:.1f}%)" if total_comparisons > 0 else "N/A")
print(f"Errors > 5%: {errors_above_5pct} ({100*errors_above_5pct/total_comparisons:.1f}%)" if total_comparisons > 0 else "N/A")
print()
if total_comparisons > 0 and max_error < 5 and errors_above_2pct / total_comparisons < 0.1:
print("RESULT: PASS - Tables match API within acceptable tolerance")
return 0
else:
print("RESULT: FAIL - Tables have significant deviation from API")
return 1
if __name__ == "__main__":
sys.exit(main())