2d7413666c
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
527 lines
25 KiB
Nim
527 lines
25 KiB
Nim
# SNNBot — SNN core + aiming loop prototype (issue #152).
|
||
# 80-input population-coded → 12 LIF hidden → polar-coded (sin/cos) output decoded via atan2.
|
||
# Inputs: relVelDir [0-35], speed thermometer [36-43], distance thermometer [44-51]. Raw bearing removed (irrelevant to lead).
|
||
# No movement, no firing. SuperSpike three-factor rule on all weights (issue #158).
|
||
# Binary reservoir alternative toggled via USE_RESERVOIR const (issue #159).
|
||
#
|
||
# State machine:
|
||
# DECIDE → feed bearing+velocity into SNN/reservoir, store targetAngle, → WAITING
|
||
# WAITING → aimTo() each tick; when error < 2° → EVALUATE
|
||
# EVALUATE → measure error, compute SuperSpike/reservoir update, log, → DECIDE
|
||
|
||
import std/[math, random, os, strutils, strformat]
|
||
import robocode_tankroyale_botapi
|
||
import radar_lock/radar_lock as radar_lock
|
||
import reservoir
|
||
|
||
# Diagnostic log file for bullet economy tracking (independent of test framework)
|
||
let diagnosticLog = "/tmp/snnbot_diagnostic.log"
|
||
let roundStatsLog = "/tmp/snnbot_round_stats.log"
|
||
let aimDebugLog = "/tmp/snnbot_aim_debug.log"
|
||
let biasDebugLog = "/tmp/snnbot_bias_debug.log"
|
||
|
||
proc diagLog(s: string) =
|
||
try:
|
||
let f = open(diagnosticLog, fmAppend)
|
||
f.writeLine(s)
|
||
f.close()
|
||
except:
|
||
discard
|
||
|
||
proc appendLog(path, s: string) =
|
||
try:
|
||
let f = open(path, fmAppend)
|
||
f.writeLine(s)
|
||
f.close()
|
||
except:
|
||
discard
|
||
|
||
proc truncateLog(path: string) =
|
||
try:
|
||
let f = open(path, fmWrite)
|
||
f.close()
|
||
except:
|
||
discard
|
||
|
||
const USE_RESERVOIR* = true # ponytail: kept for SNN path fallback; remove when binary aimer is validated
|
||
|
||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||
|
||
const botJsonPath = currentSourcePath().parentDir / "SNNBot.json"
|
||
|
||
const
|
||
N_IN = 80 # input neurons: bearing(36) + vel_dir(36) + speed(8)
|
||
N_HID = 12 # hidden LIF neurons
|
||
BAND_DEG = 10.0 # degrees per input band
|
||
BEARING_OFFSET = 0 # neurons 0-35: relative bearing
|
||
VEL_DIR_OFFSET = 36 # neurons 36-71: velocity direction
|
||
SPEED_OFFSET = 72 # neurons 72-79: speed bands
|
||
N_SPEED_BANDS = 8
|
||
SPEED_BAND_WIDTH = 1.0 # units/tick per band
|
||
MAX_SPEED = 8.0
|
||
LEAK = 0.9 # LIF membrane leak factor
|
||
THRESH = 0.08 # ponytail: THRESH=0.08 tuned for 80-input layer; raise if firing saturates
|
||
MAX_GUN_TURN = 20.0 # max gun turn per tick (degrees)
|
||
ETA = 0.1 # SuperSpike learning rate for hidden→output weights (r_0 from paper, bumped 2x)
|
||
ETA_IH = 0.02 # ponytail: ETA_IH=0.02 bumped 4x for input→hidden (was 0.005, too conservative)
|
||
# ponytail: separate input→hidden rate; add RMaxProp optimizer if convergence still unstable
|
||
N_INFER = 10 # inference window ticks per DECIDE
|
||
# ponytail: N_INFER=10, increase if output still noisy; decrease if too slow per tick
|
||
TRACE_DECAY = 0.9 # pre-synaptic trace decay (exponential low-pass)
|
||
W_CLAMP = 1.0 # ponytail: W_CLAMP=1.0, paper says ±0.1 but that's for mV-scale voltages; our unitless THRESH=1.0 needs larger weights
|
||
BETA = 1.0 # surrogate steepness (unitless; potentials are unitless)
|
||
NOISE_AMP = 0.3 # exploration noise amplitude
|
||
# ponytail: uniform exploration noise; upgrade to annealed Gaussian if convergence needs tuning
|
||
ENERGY_GUARD = 5.0 # keep buffer; Walls hits us while we shoot
|
||
FIRE_POWER = 2.0 # fixed fire power
|
||
BULLET_SPEED = 14.0 # 20 - 3 * FIRE_POWER
|
||
COLD_K = 3 # exemplars needed before leaving cold start
|
||
PATIENCE_TICKS = 5 # ticks before first fire allowed
|
||
|
||
# ── SNN types ─────────────────────────────────────────────────────────────────
|
||
|
||
type
|
||
SNN = object
|
||
wih: array[N_IN * N_HID, float] # 80×12 input→hidden weights
|
||
wSin: array[N_HID, float] # hidden→sin-channel weights
|
||
wCos: array[N_HID, float] # hidden→cos-channel weights
|
||
vHid: array[N_HID, float] # hidden membrane potentials
|
||
preTrace: array[N_IN, float] # pre-synaptic traces (low-pass of input spikes)
|
||
# Random feedback weights B[h, o] for hidden error projection; fixed, never updated.
|
||
# Indexed as bFb[h * 2 + o], o=0 → sin, o=1 → cos.
|
||
bFb: array[N_HID * 2, float]
|
||
tick: int
|
||
lastSinOut: float # last forward pass sin output (weighted sum)
|
||
lastCosOut: float # last forward pass cos output (weighted sum)
|
||
lastSnnAngle: float # last atan2 result (degrees)
|
||
|
||
proc initSNN(snn: var SNN) =
|
||
randomize()
|
||
for w in snn.wih.mitems: w = rand(0.2) - 0.1 # init within clamp ±0.1, room to grow to ±1.0
|
||
for w in snn.wSin.mitems: w = rand(0.2) - 0.1
|
||
for w in snn.wCos.mitems: w = rand(0.2) - 0.1
|
||
for b in snn.bFb.mitems: b = rand(2.0) - 1.0 # N(0,1)-ish; fixed forever
|
||
for t in snn.preTrace.mitems: t = 0.0
|
||
snn.tick = 0
|
||
|
||
proc encodeBearing(inputs: var array[N_IN, float], bearing: float, offset: int) =
|
||
## Population-code a -180..+180 angle into 36 neurons starting at offset.
|
||
## Triangular interpolation between lo and hi band.
|
||
let norm = ((bearing + 180.0) / BAND_DEG) # 0..36
|
||
let lo = int(norm) mod 36
|
||
let hi = (lo + 1) mod 36
|
||
let frac = norm - float(int(norm))
|
||
inputs[offset + lo] = 1.0 - frac
|
||
inputs[offset + hi] = frac
|
||
|
||
proc encodeInput(bearing: float): array[N_IN, float] =
|
||
## Bearing-only encode for overlay (velocity channels stay 0).
|
||
encodeBearing(result, bearing, BEARING_OFFSET)
|
||
|
||
proc encodeInputFull(bearing: float, velDirDeg: float, speed: float,
|
||
hasVel: bool): array[N_IN, float] =
|
||
## Full 80-neuron encode: bearing + velocity direction + speed.
|
||
encodeBearing(result, bearing, BEARING_OFFSET)
|
||
if hasVel:
|
||
encodeBearing(result, velDirDeg, VEL_DIR_OFFSET)
|
||
# Speed: triangular interpolation over N_SPEED_BANDS bands, clamp to [0, MAX_SPEED]
|
||
let s = speed.clamp(0.0, MAX_SPEED)
|
||
let norm = s / SPEED_BAND_WIDTH
|
||
let lo = min(int(norm), N_SPEED_BANDS - 1)
|
||
let hi = min(lo + 1, N_SPEED_BANDS - 1)
|
||
let frac = norm - float(int(norm))
|
||
result[SPEED_OFFSET + lo] += 1.0 - frac
|
||
result[SPEED_OFFSET + hi] += frac
|
||
|
||
proc surrogateDerivative(v: float): float {.inline.} =
|
||
## σ'(U) = (1 + |β(U − ϑ)|)^{-2} — peaks at threshold, gives gradient direction.
|
||
let x = BETA * (v - THRESH)
|
||
result = 1.0 / ((1.0 + abs(x)) * (1.0 + abs(x)))
|
||
|
||
proc forward(snn: var SNN, inputs: array[N_IN, float],
|
||
spikesOut: var array[N_HID, float],
|
||
vHidSnap: var array[N_HID, float],
|
||
sinOut: var float, cosOut: var float) =
|
||
## One SNN tick. Writes hidden spikes, pre-spike voltages, and raw sin/cos outputs.
|
||
## Caller accumulates sinOut/cosOut across N_INFER ticks, then calls atan2.
|
||
inc snn.tick
|
||
|
||
# Pre-synaptic trace: low-pass of input spikes
|
||
for i in 0 ..< N_IN:
|
||
snn.preTrace[i] = TRACE_DECAY * snn.preTrace[i] + inputs[i]
|
||
|
||
# Hidden layer: LIF update
|
||
for h in 0 ..< N_HID:
|
||
var wsum = 0.0
|
||
for i in 0 ..< N_IN:
|
||
wsum += inputs[i] * snn.wih[i * N_HID + h]
|
||
# ponytail: noise removed — SuperSpike surrogate derivative provides gradient direction, no exploration needed
|
||
snn.vHid[h] = LEAK * snn.vHid[h] + wsum
|
||
vHidSnap[h] = snn.vHid[h] # snapshot voltage before reset (for surrogate)
|
||
if snn.vHid[h] >= THRESH:
|
||
spikesOut[h] = 1.0
|
||
snn.vHid[h] = 0.0
|
||
else:
|
||
spikesOut[h] = 0.0
|
||
|
||
# Output: polar-coded via sin/cos channels (raw; caller does atan2)
|
||
sinOut = 0.0
|
||
cosOut = 0.0
|
||
for h in 0 ..< N_HID:
|
||
sinOut += spikesOut[h] * snn.wSin[h]
|
||
cosOut += spikesOut[h] * snn.wCos[h]
|
||
snn.lastSinOut = sinOut
|
||
snn.lastCosOut = cosOut
|
||
snn.lastSnnAngle = arctan2(sinOut, cosOut) * 180.0 / PI
|
||
|
||
proc superSpikeUpdate(snn: var SNN,
|
||
spikes: array[N_HID, float],
|
||
vSnap: array[N_HID, float],
|
||
preTrace: array[N_IN, float],
|
||
targetAngle: float) =
|
||
## SuperSpike three-factor weight update.
|
||
## Δw = η × pre_trace × σ'(U) × error
|
||
## spikes/vSnap/preTrace: captured at DECIDE time for this inference window.
|
||
## Output error: target_rate − actual_rate (rate-coded target).
|
||
## Hidden error: projected via fixed random feedback weights B.
|
||
|
||
# Target rates for sin/cos channels: map [-1,1] → [0,1]
|
||
let tSin = (sin(degToRad(targetAngle)) + 1.0) / 2.0
|
||
let tCos = (cos(degToRad(targetAngle)) + 1.0) / 2.0
|
||
|
||
# Normalize accumulated spike counts to rates in [0,1]
|
||
# Actual output activity using spike rates
|
||
var sinAct = 0.0; var cosAct = 0.0
|
||
for h in 0 ..< N_HID:
|
||
let rate = spikes[h] / float(N_INFER)
|
||
sinAct += rate * snn.wSin[h]
|
||
cosAct += rate * snn.wCos[h]
|
||
# Map actual output to [0,1] for rate comparison
|
||
let sinActNorm = (sinAct.clamp(-1.0, 1.0) + 1.0) / 2.0
|
||
let cosActNorm = (cosAct.clamp(-1.0, 1.0) + 1.0) / 2.0
|
||
|
||
# Output error (target_rate − actual_rate)
|
||
let errSin = tSin - sinActNorm
|
||
let errCos = tCos - cosActNorm
|
||
|
||
# Update hidden→output weights: Δw = η × rate_h × σ'(U_h) × error_o
|
||
for h in 0 ..< N_HID:
|
||
let sg = surrogateDerivative(vSnap[h])
|
||
let rate = spikes[h] / float(N_INFER)
|
||
snn.wSin[h] += ETA * rate * sg * errSin
|
||
snn.wSin[h] = snn.wSin[h].clamp(-W_CLAMP, W_CLAMP)
|
||
snn.wCos[h] += ETA * rate * sg * errCos
|
||
snn.wCos[h] = snn.wCos[h].clamp(-W_CLAMP, W_CLAMP)
|
||
|
||
# Update input→hidden weights: Δw = η × preTrace_j × σ'(U_h) × error_h
|
||
# Hidden error projected via fixed random feedback: error_h = Σ_o B[h,o] × error_o
|
||
for h in 0 ..< N_HID:
|
||
let sg = surrogateDerivative(vSnap[h])
|
||
let errHid = snn.bFb[h * 2 + 0] * errSin + snn.bFb[h * 2 + 1] * errCos
|
||
for i in 0 ..< N_IN:
|
||
snn.wih[i * N_HID + h] += ETA_IH * preTrace[i] * sg * errHid
|
||
snn.wih[i * N_HID + h] = snn.wih[i * N_HID + h].clamp(-W_CLAMP, W_CLAMP)
|
||
|
||
# ── Bot state machine ─────────────────────────────────────────────────────────
|
||
|
||
type
|
||
Phase = enum DECIDE, WAITING, EVALUATE
|
||
|
||
SNNBot = ref object of Bot
|
||
snn: SNN
|
||
res: LeadGrid
|
||
phase: Phase
|
||
targetAngle: float # SNN output (absolute bearing)
|
||
enemyBearing: float # last known enemy bearing
|
||
enemyDist: float # last known distance to enemy
|
||
hasContact: bool
|
||
tick: int
|
||
lastSpikes: array[N_HID, float] # accumulated spike counts over N_INFER ticks
|
||
lastVSnap: array[N_HID, float] # hidden voltages before reset from last tick (for SuperSpike)
|
||
lastRelBearing: float # relative bearing at DECIDE time (fixed for learning)
|
||
lastEnemyX: float64 # previous tick enemy position (for velocity)
|
||
lastEnemyY: float64
|
||
hasLastPos: bool
|
||
velDirDeg: float64 # velocity direction (degrees) from last scan delta
|
||
velSpeed: float64 # speed (units/tick) from last scan delta
|
||
lastDecideGunDir: float # gun heading captured at DECIDE time for EVALUATE
|
||
lastVPerp: float # DECIDE-time vPerp, reused in EVALUATE
|
||
lastAbsBearing: float # absolute bearing to enemy captured at DECIDE time
|
||
decideEnemyX: float # enemy X frozen at DECIDE time
|
||
decideEnemyY: float # enemy Y frozen at DECIDE time
|
||
decideVelDirDeg: float # enemy vel direction frozen at DECIDE time
|
||
decideVelSpeed: float # enemy speed frozen at DECIDE time
|
||
decideDist: float # distance to enemy frozen at DECIDE time
|
||
roundTick: int # ticks elapsed in current round (reset each round)
|
||
bulletsFired: int # count of bullets fired this round
|
||
bulletsHit: int # count of bullets that hit this round
|
||
hitRateEMA: float # exponential moving average of hit rate
|
||
gridHasData: bool # false during cold-start (forward returned -999)
|
||
|
||
# ── aimTo helper ──────────────────────────────────────────────────────────────
|
||
|
||
proc aimTo(targetAngle, gunDir: float) {.inline.} =
|
||
let delta = normalizeRelativeAngle(targetAngle - gunDir)
|
||
setGunTurnRate(delta.clamp(-MAX_GUN_TURN, MAX_GUN_TURN))
|
||
|
||
# ── Debug overlay ─────────────────────────────────────────────────────────────
|
||
|
||
proc drawOverlay(bot: SNNBot, myX, myY, gunDir, enemyBearing, targetAngle: float) =
|
||
let LINE_LEN = bot.enemyDist + 50.0
|
||
# Convert degree bearing to SVG direction (SVG y-axis is inverted; 0=east, CCW+)
|
||
template toXY(bearing, len: float): (float, float) =
|
||
(myX + cos(degToRad(bearing)) * len,
|
||
myY + sin(degToRad(bearing)) * len)
|
||
|
||
# Green: enemy direction (truth)
|
||
setStrokeColor(GREEN)
|
||
setStrokeWidth(2.0)
|
||
let (ex, ey) = toXY(enemyBearing, LINE_LEN)
|
||
drawLine(myX, myY, ex, ey)
|
||
|
||
# Red: gun direction
|
||
setStrokeColor(RED)
|
||
let (gx, gy) = toXY(gunDir, LINE_LEN)
|
||
drawLine(myX, myY, gx, gy)
|
||
|
||
# Yellow: SNN target angle
|
||
setStrokeColor(YELLOW)
|
||
let (tx, ty) = toXY(targetAngle, LINE_LEN)
|
||
drawLine(myX, myY, tx, ty)
|
||
|
||
# ── Event handlers ────────────────────────────────────────────────────────────
|
||
|
||
method onScannedBot*(bot: SNNBot, e: ScannedBotEvent) =
|
||
let bx = getX(); let by = getY()
|
||
bot.enemyBearing = directionTo(bx, by, e.x, e.y)
|
||
bot.enemyDist = distanceTo(bx, by, e.x, e.y)
|
||
bot.hasContact = true
|
||
if bot.hasLastPos:
|
||
let dx = e.x - bot.lastEnemyX
|
||
let dy = e.y - bot.lastEnemyY
|
||
bot.velDirDeg = arctan2(dy, dx) * 180.0 / PI
|
||
bot.velSpeed = sqrt(dx * dx + dy * dy)
|
||
bot.lastEnemyX = e.x; bot.lastEnemyY = e.y
|
||
bot.hasLastPos = true
|
||
|
||
method onRoundStarted*(bot: SNNBot, e: RoundStartedEvent) =
|
||
setAdjustGunForBodyTurn(true)
|
||
setAdjustRadarForBodyTurn(true)
|
||
setAdjustRadarForGunTurn(true)
|
||
radar_lock.init()
|
||
bot.hasContact = false
|
||
bot.hasLastPos = false
|
||
bot.velDirDeg = 0.0
|
||
bot.velSpeed = 0.0
|
||
bot.phase = DECIDE
|
||
bot.tick = 0
|
||
bot.roundTick = 0
|
||
# Reset per-round stats
|
||
bot.bulletsFired = 0
|
||
bot.bulletsHit = 0
|
||
bot.hitRateEMA = 0.5 # optimistic start
|
||
bot.gridHasData = false
|
||
bot.lastEnemyX = 0.0
|
||
bot.lastEnemyY = 0.0
|
||
bot.lastAbsBearing = 0.0
|
||
bot.lastDecideGunDir = 0.0
|
||
bot.targetAngle = 0.0
|
||
setTargetSpeed(0.0)
|
||
setTurnRate(0.0)
|
||
|
||
method onRoundEnded*(bot: SNNBot, e: RoundEndedEventForBot) =
|
||
let rate = if bot.bulletsFired > 0: bot.bulletsHit.float / bot.bulletsFired.float * 100.0 else: 0.0
|
||
let msg = "ROUND_STATS round=" & $e.roundNumber & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "%"
|
||
diagLog(msg)
|
||
appendLog(roundStatsLog, fmt"ROUND fired={bot.bulletsFired} hit={bot.bulletsHit} rate={rate:.1f}% energy={getEnergy():.0f}")
|
||
|
||
method onGameStarted*(bot: SNNBot, e: GameStartedEventForBot) =
|
||
initSNN(bot.snn)
|
||
bot.res = initLeadGrid()
|
||
truncateLog(roundStatsLog)
|
||
truncateLog(aimDebugLog)
|
||
truncateLog(biasDebugLog)
|
||
|
||
|
||
method onBulletFired*(bot: SNNBot, e: BulletFiredEvent) =
|
||
inc bot.bulletsFired
|
||
# decay EMA toward miss on each confirmed fire
|
||
bot.hitRateEMA = bot.hitRateEMA * 0.93
|
||
|
||
method onBulletHit*(bot: SNNBot, e: BulletHitBotEvent) =
|
||
inc bot.bulletsHit
|
||
bot.hitRateEMA = bot.hitRateEMA * 0.85 + 1.0 * 0.15
|
||
|
||
# ── Main loop ─────────────────────────────────────────────────────────────────
|
||
|
||
method run*(bot: SNNBot) =
|
||
while isRunning():
|
||
inc bot.tick
|
||
inc bot.roundTick
|
||
setTargetSpeed(0.0)
|
||
setTurnRate(0.0)
|
||
|
||
if not bot.hasContact:
|
||
setRadarTurnRate(45.0)
|
||
go()
|
||
continue
|
||
|
||
let myX = getX()
|
||
let myY = getY()
|
||
let gunDir = getGunDirection()
|
||
|
||
case bot.phase
|
||
of DECIDE:
|
||
# absBearing: world-frame bearing [0,360) from directionTo — independent of bot heading
|
||
let absBearing = bot.enemyBearing
|
||
bot.lastRelBearing = normalizeRelativeAngle(absBearing - gunDir)
|
||
when USE_RESERVOIR:
|
||
var relVelDir = bot.velDirDeg - absBearing
|
||
while relVelDir >= 180.0: relVelDir -= 360.0
|
||
while relVelDir < -180.0: relVelDir += 360.0
|
||
let vPerp = bot.velSpeed * sin(degToRad(relVelDir))
|
||
let gridOffset = bot.res.forward(vPerp, bot.enemyDist)
|
||
bot.lastVPerp = vPerp
|
||
bot.lastDecideGunDir = gunDir
|
||
bot.lastAbsBearing = absBearing
|
||
bot.decideEnemyX = bot.lastEnemyX
|
||
bot.decideEnemyY = bot.lastEnemyY
|
||
bot.decideVelDirDeg = bot.velDirDeg
|
||
bot.decideVelSpeed = bot.velSpeed
|
||
bot.decideDist = bot.enemyDist
|
||
let rate = if bot.bulletsFired > 0: float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0 else: 0.0
|
||
bot.gridHasData = gridOffset > -999.0
|
||
if gridOffset <= -999.0:
|
||
bot.targetAngle = absBearing
|
||
echo "RES tick=" & $bot.tick & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% cold-start aim=" & formatFloat(absBearing, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " cells=" & $bot.res.totalCount
|
||
diagLog("DECIDE tick=" & $bot.tick & " offset=cold-start cells=" & $bot.res.totalCount)
|
||
else:
|
||
# Grid learned at BULLET_SPEED — use offset directly (no scaling needed)
|
||
bot.targetAngle = (absBearing + gridOffset + 360.0) mod 360.0
|
||
echo "RES tick=" & $bot.tick & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% offset=" & formatFloat(gridOffset, ffDecimal, 1) & " aim=" & formatFloat(bot.targetAngle, ffDecimal, 1) & " fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " cells=" & $bot.res.totalCount
|
||
diagLog("DECIDE tick=" & $bot.tick & " offset=" & formatFloat(gridOffset, ffDecimal, 2) & "° cells=" & $bot.res.totalCount)
|
||
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:
|
||
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)
|
||
let err = abs(normalizeRelativeAngle(bot.targetAngle - gunDir))
|
||
|
||
# Fire gate: gun aimed, enough energy, not in cold-start
|
||
let energy = getEnergy()
|
||
let leadOffset = normalizeRelativeAngle(bot.targetAngle - bot.lastAbsBearing)
|
||
appendLog(aimDebugLog, fmt"TICK t={bot.roundTick} err={err:.2f} offset={leadOffset:.2f} vPerp={bot.lastVPerp:.2f} dist={bot.enemyDist:.1f}")
|
||
|
||
let readyToFire = err < 2.0 and energy >= ENERGY_GUARD and bot.roundTick >= PATIENCE_TICKS
|
||
if readyToFire:
|
||
discard setFire(FIRE_POWER)
|
||
bot.phase = EVALUATE
|
||
else:
|
||
discard setFire(0.0)
|
||
|
||
of EVALUATE:
|
||
# Predictive error signal: extrapolate enemy position at bullet impact time.
|
||
if bot.hasLastPos:
|
||
# correctOffset: correct lead angle at BULLET_SPEED, using DECIDE-time snapshot
|
||
let travelTime = bot.decideDist / BULLET_SPEED
|
||
let velRad = degToRad(bot.decideVelDirDeg)
|
||
let futureX = bot.decideEnemyX + cos(velRad) * bot.decideVelSpeed * travelTime
|
||
let futureY = bot.decideEnemyY + sin(velRad) * bot.decideVelSpeed * travelTime
|
||
# correctAngle: absolute world-frame bearing [0,360) to predicted enemy position
|
||
let correctAngle = directionTo(myX, myY, futureX, futureY)
|
||
|
||
# Adaptive learning dead zone: scale by DECIDE-time enemy velocity
|
||
let t = (bot.decideVelSpeed / 8.0).clamp(0.0, 1.0)
|
||
let adaptiveDeadZone = 0.5 + (0.3 - 0.5) * t # lerp(0.5, 0.3, t)
|
||
|
||
when USE_RESERVOIR:
|
||
# correctOffset: angle offset from bare bearing that a bullet at BULLET_SPEED needs
|
||
let correctOffset = normalizeRelativeAngle(correctAngle - bot.lastAbsBearing)
|
||
let producedOffset = bot.res.forward(bot.lastVPerp, bot.decideDist)
|
||
# Bias diagnostics: log grid offset vs correct offset to detect systematic error
|
||
let biasVal = producedOffset - correctOffset
|
||
appendLog(biasDebugLog, fmt"BIAS tick={bot.roundTick} gridOffset={producedOffset:.3f} correctOffset={correctOffset:.3f} bias={biasVal:.3f} vPerp={bot.lastVPerp:.3f} dist={bot.decideDist:.1f}")
|
||
|
||
# Learn if: cold start OR error > dead zone
|
||
if producedOffset > -999.0:
|
||
let aimErr = abs(normalizeRelativeAngle(producedOffset - correctOffset))
|
||
if aimErr > adaptiveDeadZone:
|
||
bot.res.learn(bot.lastVPerp, bot.decideDist, correctOffset)
|
||
else:
|
||
bot.res.learn(bot.lastVPerp, bot.decideDist, correctOffset)
|
||
let aimErr = if producedOffset > -999.0: abs(normalizeRelativeAngle(producedOffset - correctOffset)) else: -1.0
|
||
let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0
|
||
echo "RES tick=" & $bot.tick &
|
||
" hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "%" &
|
||
" fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit &
|
||
" rate=" & (if bot.bulletsFired > 0: formatFloat(rate, ffDecimal, 1) else: "0.0") & "%" &
|
||
" energy=" & formatFloat(getEnergy(), ffDecimal, 0) &
|
||
" offset=" & (if producedOffset > -999.0: formatFloat(producedOffset, ffDecimal, 1) else: "cold") &
|
||
" correctOffset=" & formatFloat(correctOffset, ffDecimal, 1) &
|
||
" err=" & (if producedOffset > -999.0: formatFloat(aimErr, ffDecimal, 1) else: "n/a") &
|
||
" vPerp=" & formatFloat(bot.lastVPerp, ffDecimal, 2) &
|
||
" cells=" & $bot.res.totalCount
|
||
else:
|
||
let targetRel = normalizeRelativeAngle(correctAngle - bot.lastDecideGunDir)
|
||
bot.snn.superSpikeUpdate(bot.lastSpikes, bot.lastVSnap, bot.snn.preTrace, targetRel)
|
||
let err = abs(normalizeRelativeAngle(gunDir - bot.enemyBearing))
|
||
# Compute verbose logging metrics
|
||
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])
|
||
var meanWih = 0.0
|
||
for w in bot.snn.wih:
|
||
meanWih += abs(w)
|
||
meanWih /= float(N_IN * N_HID)
|
||
var meanWout = 0.0
|
||
for w in bot.snn.wSin:
|
||
meanWout += abs(w)
|
||
for w in bot.snn.wCos:
|
||
meanWout += abs(w)
|
||
meanWout /= float(2 * N_HID)
|
||
let rate = if bot.bulletsFired > 0: (float(bot.bulletsHit) / float(bot.bulletsFired) * 100.0) else: 0.0
|
||
echo "tick=" & $bot.tick & " hitEMA=" & formatFloat(bot.hitRateEMA * 100.0, ffDecimal, 0) & "% fired=" & $bot.bulletsFired & " hit=" & $bot.bulletsHit & " rate=" & formatFloat(rate, ffDecimal, 1) & "% energy=" & formatFloat(getEnergy(), ffDecimal, 0) & " 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
|
||
setRadarTurnRate(radar_lock.doRadar(getRadarDirection(), bot.enemyBearing))
|
||
|
||
# Debug overlay
|
||
drawOverlay(bot, myX, myY, gunDir, bot.enemyBearing, bot.targetAngle)
|
||
|
||
go()
|
||
|
||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||
|
||
when isMainModule:
|
||
var bot = SNNBot(phase: DECIDE)
|
||
start(bot, botJsonPath)
|