fix(SNNBot): use absolute bearing to break aim feedback loop

The input encoded relative bearing, which changes when the bot aims,
creating a closed-loop oscillation (aim→input→aim). Switching to
absolute bearing (world frame) makes the input independent of aim
commands, eliminating the feedback-induced jitter.
This commit is contained in:
2026-09-15 08:23:08 +02:00
parent b843a79313
commit 94fbde2934
+47 -65
View File
@@ -215,7 +215,6 @@ type
velSpeed: float64 # speed (units/tick) from last scan delta velSpeed: float64 # speed (units/tick) from last scan delta
lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE
lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE
aimLocked: bool # when true, skip forward() and reuse targetAngle
# ── aimTo helper ────────────────────────────────────────────────────────────── # ── aimTo helper ──────────────────────────────────────────────────────────────
@@ -255,7 +254,6 @@ method onScannedBot*(bot: SNNBot, e: ScannedBotEvent) =
bot.enemyBearing = directionTo(bx, by, e.x, e.y) bot.enemyBearing = directionTo(bx, by, e.x, e.y)
bot.enemyDist = distanceTo(bx, by, e.x, e.y) bot.enemyDist = distanceTo(bx, by, e.x, e.y)
bot.hasContact = true bot.hasContact = true
bot.aimLocked = false # release lock on fresh enemy scan
if bot.hasLastPos: if bot.hasLastPos:
let dx = e.x - bot.lastEnemyX let dx = e.x - bot.lastEnemyX
let dy = e.y - bot.lastEnemyY let dy = e.y - bot.lastEnemyY
@@ -275,7 +273,6 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) =
bot.velSpeed = 0.0 bot.velSpeed = 0.0
bot.phase = DECIDE bot.phase = DECIDE
bot.tick = 0 bot.tick = 0
bot.aimLocked = false
setTargetSpeed(0.0) setTargetSpeed(0.0)
setTurnRate(0.0) setTurnRate(0.0)
@@ -338,59 +335,55 @@ method run*(bot: SNNBot) =
case bot.phase case bot.phase
of DECIDE: of DECIDE:
if not bot.aimLocked: # absBearing: world-frame bearing [0,360) from directionTo — independent of bot heading
let relBearing = normalizeRelativeAngle(bot.enemyBearing - gunDir) let absBearing = bot.enemyBearing
bot.lastRelBearing = relBearing bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir)
when USE_RESERVOIR: when USE_RESERVOIR:
let binInput = toBinaryInput(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) let binInput = toBinaryInput(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
let aimRaw = bot.res.forward(binInput) # [0,360) or -1.0 sentinel let aimRaw = bot.res.forward(binInput) # [0,360) or -1.0 sentinel
bot.lastBinInput = binInput bot.lastBinInput = binInput
bot.lastDecideGunDir = gunDir bot.lastDecideGunDir = gunDir
if aimRaw < 0.0: if aimRaw < 0.0:
# cold start: use raw bearing directly # cold start: aim directly at enemy
bot.targetAngle = bot.enemyBearing bot.targetAngle = absBearing
echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(bot.enemyBearing, ffDecimal, 1) echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(absBearing, ffDecimal, 1)
else:
# aimRaw is absolute angle [0,360); convert to -180..180 relative then add gunDir
let aimRel = normalizeRelativeAngle(aimRaw - 180.0) # exemplars stored as 0-360, relative bearing is -180..180
bot.targetAngle = gunDir + aimRel
echo "RES tick=" & $bot.tick & " aim=" & formatFloat(aimRaw, ffDecimal, 1) & " rel=" & formatFloat(aimRel, ffDecimal, 1)
else: else:
let inputs = encodeInputFull(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) # aimRaw is absolute [0,360): use directly as target
# Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks bot.targetAngle = aimRaw
var totalSin = 0.0; var totalCos = 0.0 echo "RES tick=" & $bot.tick & " aim=" & formatFloat(aimRaw, ffDecimal, 1)
var tickSpikes: array[N_HID, float] else:
var tickVSnap: array[N_HID, float] let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos)
for h in 0 ..< N_HID: bot.lastSpikes[h] = 0.0 # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks
for _ in 0 ..< N_INFER: var totalSin = 0.0; var totalCos = 0.0
var sinT, cosT: float var tickSpikes: array[N_HID, float]
bot.snn.forward(inputs, tickSpikes, tickVSnap, sinT, cosT) var tickVSnap: array[N_HID, float]
totalSin += sinT; totalCos += cosT for h in 0 ..< N_HID: bot.lastSpikes[h] = 0.0
for h in 0 ..< N_HID: for _ in 0 ..< N_INFER:
bot.lastSpikes[h] += tickSpikes[h] # accumulate counts var sinT, cosT: float
# Store last-tick voltages for learning bot.snn.forward(inputs, tickSpikes, tickVSnap, sinT, cosT)
bot.lastVSnap = tickVSnap totalSin += sinT; totalCos += cosT
bot.snn.lastSinOut = totalSin; bot.snn.lastCosOut = totalCos
bot.snn.lastSnnAngle = arctan2(totalSin, totalCos) * 180.0 / PI
bot.targetAngle = gunDir + bot.snn.lastSnnAngle
bot.lastDecideGunDir = gunDir
# Log total spike count across inference window
var spikeCount = 0
var maxV = 0.0
for h in 0 ..< N_HID: for h in 0 ..< N_HID:
spikeCount += int(bot.lastSpikes[h]) bot.lastSpikes[h] += tickSpikes[h] # accumulate counts
maxV = max(maxV, bot.snn.vHid[h]) # Store last-tick voltages for learning
echo "tick=" & $bot.tick & " infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) bot.lastVSnap = tickVSnap
bot.snn.lastSinOut = totalSin; bot.snn.lastCosOut = totalCos
bot.snn.lastSnnAngle = arctan2(totalSin, totalCos) * 180.0 / PI
bot.targetAngle = gunDir + bot.snn.lastSnnAngle
bot.lastDecideGunDir = gunDir
# Log total spike count across inference window
var spikeCount = 0
var maxV = 0.0
for h in 0 ..< N_HID:
spikeCount += int(bot.lastSpikes[h])
maxV = max(maxV, bot.snn.vHid[h])
echo "tick=" & $bot.tick & " infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3)
bot.phase = WAITING bot.phase = WAITING
of WAITING: of WAITING:
aimTo(bot.targetAngle, gunDir) aimTo(bot.targetAngle, gunDir)
if bot.aimLocked:
discard setFire(FIRE_POWER)
else:
discard setFire(0.0)
let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir)) let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir))
if err < AIM_TOL: if err < AIM_TOL:
discard setFire(FIRE_POWER)
bot.phase = EVALUATE bot.phase = EVALUATE
of EVALUATE: of EVALUATE:
@@ -400,39 +393,28 @@ method run*(bot: SNNBot) =
let velRad = degToRad(bot.velDirDeg) let velRad = degToRad(bot.velDirDeg)
let futureX = bot.lastEnemyX + cos(velRad) * bot.velSpeed * travelTime let futureX = bot.lastEnemyX + cos(velRad) * bot.velSpeed * travelTime
let futureY = bot.lastEnemyY + sin(velRad) * bot.velSpeed * travelTime let futureY = bot.lastEnemyY + sin(velRad) * bot.velSpeed * travelTime
let absBearing = directionTo(myX, myY, futureX, futureY) # correctAngle: absolute world-frame bearing [0,360) to predicted enemy position
let targetRel = normalizeRelativeAngle(absBearing - bot.lastDecideGunDir) let correctAngle = directionTo(myX, myY, futureX, futureY)
when USE_RESERVOIR: when USE_RESERVOIR:
# correctAngle must be [0,360): targetRel is -180..180, shift it
var correctAngle = targetRel + 180.0
let aimRaw = bot.res.forward(bot.lastBinInput) let aimRaw = bot.res.forward(bot.lastBinInput)
# Dead zone: only learn if aim error > 1.5° (prevents ring buffer churn) # Dead zone: only learn if aim error > 1.5° (prevents ring buffer churn)
if aimRaw >= 0.0: if aimRaw >= 0.0:
let aimErr = abs(normalizeRelativeAngle(aimRaw - correctAngle)) let aimErr = abs(normalizeRelativeAngle(aimRaw - correctAngle))
if aimErr > 1.5: if aimErr > 1.5:
bot.res.learn(bot.lastBinInput, correctAngle) bot.res.learn(bot.lastBinInput, correctAngle)
bot.aimLocked = false # error still large, keep recomputing
else:
bot.aimLocked = true # error within dead zone, freeze aim
else: else:
# Cold start: always learn # Cold start: always learn
bot.res.learn(bot.lastBinInput, correctAngle) bot.res.learn(bot.lastBinInput, correctAngle)
bot.aimLocked = false
let aimErr = if aimRaw >= 0.0: abs(normalizeRelativeAngle(aimRaw - correctAngle)) else: -1.0 let aimErr = if aimRaw >= 0.0: abs(normalizeRelativeAngle(aimRaw - correctAngle)) else: -1.0
echo "RES tick=" & $bot.tick & echo "RES tick=" & $bot.tick &
" aim=" & (if aimRaw >= 0.0: formatFloat(aimRaw, ffDecimal, 1) else: "cold") & " aim=" & (if aimRaw >= 0.0: formatFloat(aimRaw, ffDecimal, 1) else: "cold") &
" correct=" & formatFloat(correctAngle, ffDecimal, 1) & " correct=" & formatFloat(correctAngle, ffDecimal, 1) &
" err=" & (if aimRaw >= 0.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & " err=" & (if aimRaw >= 0.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") &
" exemplars=" & $bot.res.count & " exemplars=" & $bot.res.count
" lock=" & (if bot.aimLocked: "ON" else: "OFF")
else: else:
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing)) let targetRel = normalizeRelativeAngle(correctAngle - bot.lastDecideGunDir)
bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel) bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel)
# Aim lock: freeze when error within 1.5° threshold let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
if err < 1.5:
bot.aimLocked = true
else:
bot.aimLocked = false
# Compute verbose logging metrics # Compute verbose logging metrics
var spikeCount = 0 var spikeCount = 0
var maxV = 0.0 var maxV = 0.0
@@ -449,7 +431,7 @@ method run*(bot: SNNBot) =
for w in bot.snn.wCos: for w in bot.snn.wCos:
meanWout += abs(w) meanWout += abs(w)
meanWout /= float(2 * N_HID) meanWout /= float(2 * N_HID)
echo "tick=" & $bot.tick & " err=" & formatFloat(err, ffDecimal, 1) & "° infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) & " |wih|=" & formatFloat(meanWih, ffDecimal, 4) & " |wOut|=" & formatFloat(meanWout, ffDecimal, 4) & " sin=" & formatFloat(bot.snn.lastSinOut, ffDecimal, 3) & " cos=" & formatFloat(bot.snn.lastCosOut, ffDecimal, 3) & " snnAngle=" & formatFloat(bot.snn.lastSnnAngle, ffDecimal, 1) & " lock=" & (if bot.aimLocked: "ON" else: "OFF") echo "tick=" & $bot.tick & " err=" & formatFloat(err, ffDecimal, 1) & "° infer_spk=" & $spikeCount & "/" & $(N_INFER * N_HID) & " maxV=" & formatFloat(maxV, ffDecimal, 3) & " |wih|=" & formatFloat(meanWih, ffDecimal, 4) & " |wOut|=" & formatFloat(meanWout, ffDecimal, 4) & " sin=" & formatFloat(bot.snn.lastSinOut, ffDecimal, 3) & " cos=" & formatFloat(bot.snn.lastCosOut, ffDecimal, 3) & " snnAngle=" & formatFloat(bot.snn.lastSnnAngle, ffDecimal, 1)
bot.phase = DECIDE bot.phase = DECIDE
# Radar lock # Radar lock