From 94fbde293432ddf78c385a12bc3998e9a110af7b Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Tue, 15 Sep 2026 08:23:08 +0200 Subject: [PATCH] fix(SNNBot): use absolute bearing to break aim feedback loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- SNNBot_garage/src/SNNBot.nim | 112 +++++++++++++++-------------------- 1 file changed, 47 insertions(+), 65 deletions(-) diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim index b0d2202..e72fbd6 100644 --- a/SNNBot_garage/src/SNNBot.nim +++ b/SNNBot_garage/src/SNNBot.nim @@ -215,7 +215,6 @@ type velSpeed: float64 # speed (units/tick) from last scan delta lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE lastBinInput: BitVec80 # DECIDE-time binary input, reused in EVALUATE - aimLocked: bool # when true, skip forward() and reuse targetAngle # ── aimTo helper ────────────────────────────────────────────────────────────── @@ -255,7 +254,6 @@ method onScannedBot*(bot: SNNBot, e: ScannedBotEvent) = bot.enemyBearing = directionTo(bx, by, e.x, e.y) bot.enemyDist = distanceTo(bx, by, e.x, e.y) bot.hasContact = true - bot.aimLocked = false # release lock on fresh enemy scan if bot.hasLastPos: let dx = e.x - bot.lastEnemyX let dy = e.y - bot.lastEnemyY @@ -275,7 +273,6 @@ method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) = bot.velSpeed = 0.0 bot.phase = DECIDE bot.tick = 0 - bot.aimLocked = false setTargetSpeed(0.0) setTurnRate(0.0) @@ -338,59 +335,55 @@ method run*(bot: SNNBot) = case bot.phase of DECIDE: - if not bot.aimLocked: - let relBearing = normalizeRelativeAngle(bot.enemyBearing - gunDir) - bot.lastRelBearing = relBearing - when USE_RESERVOIR: - let binInput = toBinaryInput(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) - let aimRaw = bot.res.forward(binInput) # [0,360) or -1.0 sentinel - bot.lastBinInput = binInput - bot.lastDecideGunDir = gunDir - if aimRaw < 0.0: - # cold start: use raw bearing directly - bot.targetAngle = bot.enemyBearing - echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(bot.enemyBearing, 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) + # absBearing: world-frame bearing [0,360) from directionTo — independent of bot heading + let absBearing = bot.enemyBearing + bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir) + when USE_RESERVOIR: + let binInput = toBinaryInput(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) + let aimRaw = bot.res.forward(binInput) # [0,360) or -1.0 sentinel + bot.lastBinInput = binInput + bot.lastDecideGunDir = gunDir + if aimRaw < 0.0: + # cold start: aim directly at enemy + bot.targetAngle = absBearing + echo "RES tick=" & $bot.tick & " cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) else: - let inputs = encodeInputFull(relBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) - # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks - var totalSin = 0.0; var totalCos = 0.0 - var tickSpikes: array[N_HID, float] - var tickVSnap: array[N_HID, float] - for h in 0 ..< N_HID: bot.lastSpikes[h] = 0.0 - for _ in 0 ..< N_INFER: - var sinT, cosT: float - bot.snn.forward(inputs, tickSpikes, tickVSnap, sinT, cosT) - totalSin += sinT; totalCos += cosT - for h in 0 ..< N_HID: - bot.lastSpikes[h] += tickSpikes[h] # accumulate counts - # Store last-tick voltages for learning - 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 + # aimRaw is absolute [0,360): use directly as target + bot.targetAngle = aimRaw + echo "RES tick=" & $bot.tick & " aim=" & formatFloat(aimRaw, ffDecimal, 1) + else: + let inputs = encodeInputFull(absBearing, bot.velDirDeg, bot.velSpeed, bot.hasLastPos) + # Multi-tick inference: accumulate sin/cos and spike counts over N_INFER ticks + var totalSin = 0.0; var totalCos = 0.0 + var tickSpikes: array[N_HID, float] + var tickVSnap: array[N_HID, float] + for h in 0 ..< N_HID: bot.lastSpikes[h] = 0.0 + for _ in 0 ..< N_INFER: + var sinT, cosT: float + bot.snn.forward(inputs, tickSpikes, tickVSnap, sinT, cosT) + totalSin += sinT; totalCos += cosT 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.lastSpikes[h] += tickSpikes[h] # accumulate counts + # Store last-tick voltages for learning + 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 of WAITING: aimTo(bot.targetAngle, gunDir) - if bot.aimLocked: - discard setFire(FIRE_POWER) - else: - discard setFire(0.0) let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir)) if err < AIM_TOL: + discard setFire(FIRE_POWER) bot.phase = EVALUATE of EVALUATE: @@ -400,39 +393,28 @@ method run*(bot: SNNBot) = let velRad = degToRad(bot.velDirDeg) let futureX = bot.lastEnemyX + cos(velRad) * bot.velSpeed * travelTime let futureY = bot.lastEnemyY + sin(velRad) * bot.velSpeed * travelTime - let absBearing = directionTo(myX, myY, futureX, futureY) - let targetRel = normalizeRelativeAngle(absBearing - bot.lastDecideGunDir) + # correctAngle: absolute world-frame bearing [0,360) to predicted enemy position + let correctAngle = directionTo(myX, myY, futureX, futureY) 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) # Dead zone: only learn if aim error > 1.5° (prevents ring buffer churn) if aimRaw >= 0.0: let aimErr = abs(normalizeRelativeAngle(aimRaw - correctAngle)) if aimErr > 1.5: 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: # Cold start: always learn bot.res.learn(bot.lastBinInput, correctAngle) - bot.aimLocked = false let aimErr = if aimRaw >= 0.0: abs(normalizeRelativeAngle(aimRaw - correctAngle)) else: -1.0 echo "RES tick=" & $bot.tick & " aim=" & (if aimRaw >= 0.0: formatFloat(aimRaw, ffDecimal, 1) else: "cold") & " correct=" & formatFloat(correctAngle, ffDecimal, 1) & " err=" & (if aimRaw >= 0.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") & - " exemplars=" & $bot.res.count & - " lock=" & (if bot.aimLocked: "ON" else: "OFF") + " exemplars=" & $bot.res.count 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) - # Aim lock: freeze when error within 1.5° threshold - if err < 1.5: - bot.aimLocked = true - else: - bot.aimLocked = false + let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing)) # Compute verbose logging metrics var spikeCount = 0 var maxV = 0.0 @@ -449,7 +431,7 @@ method run*(bot: SNNBot) = for w in bot.snn.wCos: meanWout += abs(w) 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 # Radar lock