feat(SNNBot): encode perpendicular velocity for unambiguous lead

relVelDir ±5° shared 80% of bits despite needing opposite lead.
Now encodes v_perp = speed×sin(relVelDir) directly with signed
thermometer coding — positive and negative crossing velocities
have zero bit overlap. Also adds v_parallel for approach/recede.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 23:15:34 +02:00
parent 929fe5a696
commit 4bc7baee04
+33 -24
View File
@@ -321,40 +321,46 @@ method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) =
# ── Reservoir helpers ───────────────────────────────────────────────────────── # ── Reservoir helpers ─────────────────────────────────────────────────────────
proc toBinaryInput(bearing: float, velDir: float, velSpeed: float, distance: float, hasVel: bool): BitVec80 = proc toBinaryInput(relVelDir: float, velSpeed: float, distance: float, hasVel: bool): BitVec80 =
## Physics-based encoding for lead generalization (issue #152). ## Physics-based encoding for lead generalization (issue #152).
## Raw bearing removed — irrelevant to lead offset. ## Bits 0-15: v_perp signed thermometer — velSpeed*sin(relVelDir) in [-8,+8].
## Bits 0-35: relVelDir = (velDir - bearing) normalized to [-180,180), 36 bins × 10°, 5 active (center ± 2 neighbors). ## Positive (target moves right): bits 0-7; negative (left): bits 8-15.
## Bits 36-43: speed thermometer (8 bits): speedInt bits set from bit 36 upward, 0-8 active. ## +3 sets bits 0,1,2; -3 sets bits 8,9,10. Zero overlap between signs.
## Adjacent speeds differ by 1 bit (Hamming-1), giving smooth generalization. ## Bits 16-31: v_parallel signed thermometer — velSpeed*cos(relVelDir) in [-8,+8].
## Bits 44-51: distance thermometer, 8 bands of ~125px in [0,1000]. ## Positive = approaching (less lead needed), negative = receding.
## Bits 52-79: unused. ## Bits 32-39: distance thermometer, 8 bands of ~125px in [0,1000].
## ponytail: physics-based; revert to absolute bearing if convergence regresses ## Bits 40-47: total speed thermometer, 8 bands of 1 unit/tick.
## Bits 48-79: unused.
result = [0'u64, 0'u64] result = [0'u64, 0'u64]
if hasVel: template setThermo(baseIdx: int, value: float) =
# Relative velocity direction: how the target crosses our line of fire ## Signed thermometer: positive → bits baseIdx..baseIdx+7, negative → bits baseIdx+8..baseIdx+15.
var relVelDir = (velDir - bearing) let mag = min(int(abs(value)), 8)
while relVelDir >= 180.0: relVelDir -= 360.0 if value >= 0.0:
while relVelDir < -180.0: relVelDir += 360.0 for b in 0 ..< mag:
let vNorm = (relVelDir + 180.0) / 10.0 # 0..36 let idx = baseIdx + b
let vCenter = int(vNorm) mod 36 result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64))
for offset in -2 .. 2: else:
let idx = (vCenter + offset + 36) mod 36 # bits 0-35 for b in 0 ..< mag:
let idx = baseIdx + 8 + b
result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64)) result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64))
# Speed thermometer: bits 36-43; set bits 36..(36+speedInt-1) if hasVel:
# speedInt=0 → no bits; speedInt=8 → all 8 bits set let vPerp = velSpeed * sin(degToRad(relVelDir)) # lateral speed, range [-8,+8]
let vPar = velSpeed * cos(degToRad(relVelDir)) # approach speed, range [-8,+8]
setThermo(0, vPerp)
setThermo(16, vPar)
# Total speed thermometer: bits 40-47
let speedInt = clamp(int(velSpeed + 0.5), 0, 8) let speedInt = clamp(int(velSpeed + 0.5), 0, 8)
for b in 0 ..< speedInt: for b in 0 ..< speedInt:
let idx = 36 + b let idx = 40 + b
result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64)) result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64))
# Distance thermometer: bits 44-51, 8 bands of ~125px over [0,1000] # Distance thermometer: bits 32-39, 8 bands of ~125px over [0,1000]
# All bits up to the current band set (inclusive)
let band = clamp(int(distance / 125.0), 0, 7) let band = clamp(int(distance / 125.0), 0, 7)
for b in 0 .. band: for b in 0 .. band:
let idx = 44 + b let idx = 32 + b
result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64)) result[idx div 64] = result[idx div 64] or (1'u64 shl (idx mod 64))
# ── Main loop ───────────────────────────────────────────────────────────────── # ── Main loop ─────────────────────────────────────────────────────────────────
@@ -381,7 +387,10 @@ method run*(bot: SNNBot) =
let absBearing = bot.enemyBearing let absBearing = bot.enemyBearing
bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir) bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir)
when USE_RESERVOIR: when USE_RESERVOIR:
let binInput = toBinaryInput(absBearing, bot.velDirDeg, bot.velSpeed, bot.enemyDist, bot.hasLastPos) var relVelDir = bot.velDirDeg - absBearing
while relVelDir >= 180.0: relVelDir -= 360.0
while relVelDir < -180.0: relVelDir += 360.0
let binInput = toBinaryInput(relVelDir, bot.velSpeed, bot.enemyDist, bot.hasLastPos)
let aimOffset = bot.res.forward(binInput) # lead correction offset or -999.0 sentinel let aimOffset = bot.res.forward(binInput) # lead correction offset or -999.0 sentinel
bot.lastBinInput = binInput bot.lastBinInput = binInput
bot.lastDecideGunDir = gunDir bot.lastDecideGunDir = gunDir