From c05fb7a32b4bae8ddc40714faef19ee726421e45 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Tue, 15 Sep 2026 08:14:28 +0200 Subject: [PATCH] fix(SNNBot): hold aim when error is within dead zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds aim-lock mechanism to prevent re-running forward() pass during DECIDE when the error is already within 1.5° dead zone. Lock is set in EVALUATE when error < 1.5°, reusing the previous targetAngle in DECIDE. Lock releases on fresh enemy scan or when error exceeds threshold, eliminating the ±0.9° wobble from input pattern drift. Co-Authored-By: Claude Opus 4.6 --- SNNBot_garage/src/SNNBot.nim | 98 ++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/SNNBot_garage/src/SNNBot.nim b/SNNBot_garage/src/SNNBot.nim index a1b8864..8f7b699 100644 --- a/SNNBot_garage/src/SNNBot.nim +++ b/SNNBot_garage/src/SNNBot.nim @@ -215,6 +215,7 @@ 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 ────────────────────────────────────────────────────────────── @@ -254,6 +255,7 @@ 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 @@ -273,6 +275,7 @@ 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) @@ -335,48 +338,49 @@ method run*(bot: SNNBot) = case bot.phase of DECIDE: - 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) + 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) 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: - 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 + 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 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 - 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) + 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: @@ -404,18 +408,28 @@ method run*(bot: SNNBot) = 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 + " exemplars=" & $bot.res.count & + " lock=" & (if bot.aimLocked: "ON" else: "OFF") else: let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing)) 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 # Compute verbose logging metrics var spikeCount = 0 var maxV = 0.0 @@ -432,7 +446,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) + 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") bot.phase = DECIDE # Radar lock