package com.elphel.imagej.cuas.rt;

/**
 * Layer 2 recurrent accumulation buffer for the CUAS real-time detector.
 * By Claude (Fable 5) on 06/09/2026, design decisions by Andrey Filippov (2026-06-09).
 * Conditioning rewritten to half-cosine model fit, velocity diffusion added to the
 * predict step - by Claude on 06/10/2026, design by Andrey Filippov (2026-06-10).
 *
 * Maintains a 4D state (px, py, vx, vy) over an ROI, same shape as the ROI output
 * of CuasRTUtils.convolve3d()/filterConv5dROI(): [roi_npix][num_sub][vel_dim*vel_dim].
 * The sub-pixel dimension is carried through unchanged (pixel_decimate is currently 1).
 *
 * Update rule (applied once per `period` time units):
 *   buf_new = resharpen( leak * (1 - w_eff) * diffuse(shift(buf_old, v)) + w_eff * conditioned(measurement) )
 *
 * - shift(buf, v): every 4D point moves by its own velocity vector. The pixel shift
 *   over `period` time units is (vx_off * period / velocity_decimate) - exactly
 *   integer (1 px per minimum velocity step) when period == velocity_decimate.
 *   Points predicted outside the ROI are dropped (target left the watched area).
 * - diffuse(): process-noise diffusion in the velocity dimensions (3-tap
 *   (spread, 1, spread), normalized to conserve energy; spread <= 0 disables).
 *   The shift alone is 1-to-1 - each 4D point keeps its velocity forever - so a
 *   target at a half-grid velocity could never migrate the buffer maximum to the
 *   neighboring velocity cell: the v-component would fall 0.5 px/update behind the
 *   measurements and accumulation would fragment. Diffusion provides the migration
 *   channel: energy leaking to the neighboring velocity travels at that velocity,
 *   stays aligned with the measurement injections and gets reinforced, while the
 *   misaligned component decays (leak + no reinforcement). Position dimensions
 *   need no explicit diffusion - they are coupled through the shift itself plus
 *   the +/-1 spreading of the conditioned input.
 * - conditioned(measurement): model-based reconstruction of the filterConv5dROI
 *   survivors, see condition(). The target response is modeled as a separable 4D
 *   half-cosine bump with a 3-pixel half-period (per-dimension samples
 *   0.5 : 1.0 : 0.5 around an on-grid center), centered on the half-pixel grid.
 *   Survivors are sparse samples of that bump (the filter zeroed the rest); the
 *   bump is fitted to them and its full grid sampling is injected. No
 *   renormalization - the center keeps the fitted peak, so conditioned values are
 *   in the same units as the filter output (wthresh compares directly).
 * - resharpen() (rs_frac > 0, measurement present): Kalman-like shape update.
 *   The shift shears each blob (velocity slices move at their own velocities) -
 *   the physically exact growth of position uncertainty from velocity
 *   uncertainty - and the merge pulls the blob centroid sub-cell toward the
 *   measurement. Re-sharpening peels 4D blobs (strongest cell first, growing
 *   over connected cells above rs_frac of that blob's max, down to blob maxima
 *   of rs_min) and re-synthesizes each as a canonical half-cosine bump at its
 *   FRACTIONAL energy centroid (no half-grid rounding - rounding hysteresis
 *   would block migration), with peak = E / S(c) so the injected sparse grid
 *   sampling totals exactly the claimed energy E at any sub-cell phase.
 *   Sub-threshold residue passes through unchanged. The fractional center is
 *   the sub-cell state the grid alone cannot hold: the maximum hops to the
 *   adjacent velocity cell when the center crosses the half-grid boundary, so
 *   half-grid velocities track without accumulating blur (each update's shear
 *   is consumed by the re-fit). Skipped during gaps - shear then accumulates
 *   as honest uncertainty growth (the Kalman Q analog). With re-sharpening on,
 *   explicit diffusion is unnecessary: keep spread = 0 for the shear-only
 *   scheme (spread kept for A/B comparison).
 * - w_eff: fixed `w` when wthresh <= 0; otherwise per-pixel adaptive
 *   w_eff = w * tanh(strength / wthresh), where strength is the strongest conditioned
 *   measurement at that pixel. Dark frames then give w_eff ~ 0 (pure prediction),
 *   and `leak` alone controls gap survival: leak^n_gap >= 0.2-0.3 recommended.
 *   With fixed w, the effective per-update gap decay is leak * (1 - w).
 *
 * This is intentionally a simplified fixed/adaptive-gain filter, NOT a Kalman filter -
 * the 4D map state is too large; a proper KF is reserved for the per-target worker stage.
 * Single-threaded for now (ROI is small); optimize later.
 */
public class CuasRecurrentBuffer {
	private static final double W_MIN = 0.02; // minimal model weight for a survivor to be explainable by the bump fit
	private final int roi_width;
	private final int roi_height;
	private final int num_sub;            // pixel_decimate^2
	private final int vel_dim;            // 2*velocity_radius + 1
	private final int velocity_radius;
	private final int velocity_decimate;
	private final int period;             // time units between updates
	private final double spread;          // predict-step velocity diffusion weight (3-tap (spread,1,spread) normalized; <=0 - off)
	private final int max_clust;          // maximal 4D cluster size treated as a compact target; larger - linear mode
	private final double fit_rat;         // max allowed max(P_i)/min(P_i) of per-survivor peak estimates for the bump fit to be accepted
	private final double rs_frac;         // re-sharpen: blob growth threshold as a fraction of the blob max; <=0 - re-sharpening off
	private final double rs_min;          // re-sharpen: minimal blob maximum to re-fit; weaker content passes through as residue
	private double [][][] buffer;         // [roi_width*roi_height][num_sub][vel_dim*vel_dim]
	private int num_updates = 0;
	private boolean save_stages = false;        // keep per-update intermediate stages for debug imaging // By Claude on 06/10/2026
	private double [][][] last_cond = null;     // conditioned measurement of the last update (zeros for a gap) // By Claude on 06/10/2026
	private double [][][] pre_resharpen = null; // last merged state before re-sharpening // By Claude on 06/10/2026

	public CuasRecurrentBuffer (
			int roi_width,
			int roi_height,
			int num_sub,
			int velocity_radius,
			int velocity_decimate,
			int period,
			double spread,
			int max_clust,
			double fit_rat,
			double rs_frac,
			double rs_min) {
		this.roi_width =         roi_width;
		this.roi_height =        roi_height;
		this.num_sub =           num_sub;
		this.velocity_radius =   velocity_radius;
		this.vel_dim =           2 * velocity_radius + 1;
		this.velocity_decimate = velocity_decimate;
		this.period =            period;
		this.spread =            spread;
		this.max_clust =         max_clust;
		this.fit_rat =           fit_rat;
		this.rs_frac =           rs_frac;
		this.rs_min =            rs_min;
		this.buffer = new double [roi_width*roi_height][num_sub][vel_dim*vel_dim];
	}

	public double [][][] getBuffer() {
		return buffer;
	}

	public double [][][] getBufferCopy() {
		return deepCopy(buffer);
	}

	private double [][][] deepCopy(double [][][] src) { // By Claude on 06/10/2026
		double [][][] copy = new double [src.length][num_sub][vel_dim*vel_dim];
		for (int p = 0; p < src.length; p++) for (int s = 0; s < num_sub; s++) {
			System.arraycopy(src[p][s], 0, copy[p][s], 0, src[p][s].length);
		}
		return copy;
	}

	/** Enable keeping per-update intermediate stages (conditioned measurement, pre-resharpen state) for debug imaging. */
	public void setSaveStages(boolean save_stages) { // By Claude on 06/10/2026
		this.save_stages = save_stages;
	}

	/** Conditioned measurement of the last update (zeros array for a gap); null until the first update with stages enabled. */
	public double [][][] getLastConditioned() { // By Claude on 06/10/2026
		return last_cond;
	}

	/** State after the merge, before re-sharpening (== final state when re-sharpening was skipped); null until the first update with stages enabled. */
	public double [][][] getPreResharpen() { // By Claude on 06/10/2026
		return pre_resharpen;
	}

	public int getNumUpdates() {
		return num_updates;
	}

	/**
	 * One recurrent update step.
	 * @param measurement filterConv5dROI() output for the current time step,
	 *                    [roi_npix][num_sub][vel_dim*vel_dim]; may be null (dark/skipped
	 *                    frame) - then the update is pure decayed prediction.
	 * @param w       measurement weight (fixed gain), 0..1
	 * @param leak    extra decay of the predicted (shifted) buffer per update
	 * @param wthresh if > 0, per-pixel adaptive gain w_eff = w*tanh(strength/wthresh)
	 */
	public void update (
			double [][][] measurement,
			double        w,
			double        leak,
			double        wthresh) {
		double [][][] shifted = shiftByVelocity(buffer);
		if (spread > 0) diffuseVelocity(shifted); // process noise: lets the maximum migrate between velocity cells
		double [][][] cond = (measurement != null) ? condition(measurement) : null;
		if (save_stages) { // By Claude on 06/10/2026
			last_cond = (cond != null) ? cond : new double [buffer.length][num_sub][vel_dim*vel_dim];
		}
		for (int p = 0; p < buffer.length; p++) {
			double w_eff = w;
			if (cond == null) {
				w_eff = 0.0;
			} else if (wthresh > 0) { // adaptive gain from the strongest conditioned measurement at this pixel
				double strength = 0;
				for (int s = 0; s < num_sub; s++) for (int v = 0; v < cond[p][s].length; v++) {
					if (cond[p][s][v] > strength) strength = cond[p][s][v];
				}
				w_eff = w * Math.tanh(strength / wthresh);
			}
			for (int s = 0; s < num_sub; s++) {
				for (int v = 0; v < buffer[p][s].length; v++) {
					double pred = leak * (1.0 - w_eff) * shifted[p][s][v];
					double meas = (cond != null) ? (w_eff * cond[p][s][v]) : 0.0;
					buffer[p][s][v] = pred + meas;
				}
			}
		}
		if (save_stages) { // By Claude on 06/10/2026
			pre_resharpen = deepCopy(buffer);
		}
		if ((cond != null) && (rs_frac > 0)) { // re-fit merged blobs; skipped during gaps - shear accumulates as honest uncertainty // By Claude on 06/10/2026
			resharpen();
		}
		num_updates++;
	}

	/**
	 * Blind prediction: every 4D point moves by its own velocity, pixel shift =
	 * velocity * period (integer-clean when period == velocity_decimate).
	 * Multiple sources landing on the same destination are summed.
	 */
	private double [][][] shiftByVelocity(double [][][] src) {
		double [][][] dst = new double [src.length][num_sub][vel_dim*vel_dim];
		for (int p = 0; p < src.length; p++) {
			int px = p % roi_width;
			int py = p / roi_width;
			for (int s = 0; s < num_sub; s++) {
				for (int v = 0; v < src[p][s].length; v++) {
					double val = src[p][s][v];
					if (val == 0) continue;
					int vx_off = (v % vel_dim) - velocity_radius;
					int vy_off = (v / vel_dim) - velocity_radius;
					int dx = (int) Math.round(((double) vx_off * period) / velocity_decimate);
					int dy = (int) Math.round(((double) vy_off * period) / velocity_decimate);
					int nx = px + dx;
					int ny = py + dy;
					if ((nx < 0) || (nx >= roi_width) || (ny < 0) || (ny >= roi_height)) {
						continue; // predicted out of the ROI - drop
					}
					dst[ny * roi_width + nx][s][v] += val;
				}
			}
		}
		return dst;
	}

	/**
	 * In-place process-noise diffusion over the velocity dimensions: separable
	 * 3-tap (spread, 1, spread) in vx then vy, each source renormalized over its
	 * in-bounds taps so total energy is conserved (leak remains the only decay).
	 * This is the migration channel that the 1-to-1 shift lacks: without it a
	 * half-grid-velocity target can never move the buffer maximum to the adjacent
	 * velocity cell (see class doc).
	 */
	private void diffuseVelocity(double [][][] data) { // By Claude on 06/10/2026
		final double k = spread;
		final double [] line = new double [vel_dim];
		for (int p = 0; p < data.length; p++) {
			for (int s = 0; s < num_sub; s++) {
				double [] plane = data[p][s];
				for (int vy = 0; vy < vel_dim; vy++) { // vx pass
					int base = vy * vel_dim;
					System.arraycopy(plane, base, line, 0, vel_dim);
					java.util.Arrays.fill(plane, base, base + vel_dim, 0.0);
					for (int vx = 0; vx < vel_dim; vx++) {
						double val = line[vx];
						if (val == 0) continue;
						double sum_w = 1.0 + ((vx > 0) ? k : 0) + ((vx < (vel_dim - 1)) ? k : 0);
						double scale = val / sum_w;
						plane[base + vx] += scale;
						if (vx > 0)             plane[base + vx - 1] += scale * k;
						if (vx < (vel_dim - 1)) plane[base + vx + 1] += scale * k;
					}
				}
				for (int vx = 0; vx < vel_dim; vx++) { // vy pass
					for (int vy = 0; vy < vel_dim; vy++) line[vy] = plane[vy * vel_dim + vx];
					for (int vy = 0; vy < vel_dim; vy++) plane[vy * vel_dim + vx] = 0.0;
					for (int vy = 0; vy < vel_dim; vy++) {
						double val = line[vy];
						if (val == 0) continue;
						double sum_w = 1.0 + ((vy > 0) ? k : 0) + ((vy < (vel_dim - 1)) ? k : 0);
						double scale = val / sum_w;
						plane[vy * vel_dim + vx] += scale;
						if (vy > 0)             plane[(vy - 1) * vel_dim + vx] += scale * k;
						if (vy < (vel_dim - 1)) plane[(vy + 1) * vel_dim + vx] += scale * k;
					}
				}
			}
		}
	}

	/**
	 * Model-based conditioning of the filtered measurement. // By Claude on 06/10/2026
	 * The target response is modeled as a separable 4D half-cosine bump,
	 *   B(x) = P * prod_d max(0, cos(PI/3 * (x_d - c_d))),
	 * 3-pixel half-period in every dimension (per-dimension grid samples
	 * 0.5 : 1.0 : 0.5 around an on-grid center), center c on the half-pixel grid.
	 * The filterConv5dROI survivors are sparse samples of that bump; the filter
	 * zeroed the weaker samples and does not conserve energy.
	 *
	 * Survivors (value > 0) are grouped into 4D-connected clusters (3^4-1
	 * connectivity in (px, py, vx, vy)). Per cluster:
	 * - more than max_clust survivors: weak signal that passed the filter linearly
	 *   - identity pass-through, no reconstruction.
	 * - up to max_clust survivors: fit the bump - c = value-weighted centroid
	 *   rounded per-dimension to the half-pixel grid, amplitude P by least squares
	 *   (P = sum(v_i*w_i)/sum(w_i^2), w_i the model weight at survivor i). A single
	 *   survivor reduces to c = its position, P = its value. Inject the full grid
	 *   sampling of the fitted bump (center keeps P, no normalization). This also
	 *   fills samples the filter zeroed: e.g. a diagonal pair (0,0)+(1,1) gets its
	 *   missing (1,0)+(0,1) corners at the same model level.
	 * - fit rejected (some survivor has model weight < W_MIN, or the per-survivor
	 *   peak estimates P_i = v_i/w_i disagree by more than fit_rat): the arrangement
	 *   does not match a single center-symmetric bump (e.g. a flat ridge wider than
	 *   the model). Fallback: each survivor injects its own single-point bump
	 *   (case-1 per survivor) - keeps the +/-1 drift tolerance the recurrent
	 *   accumulation needs; the resulting widening honestly represents the position
	 *   uncertainty. (Survivors >= 2 cells apart are not 4D-connected, so e.g. the
	 *   x=-1/x=+1-with-gap arrangement arrives here as two singleton clusters and
	 *   gets the same per-point treatment automatically.)
	 */
	private double [][][] condition(double [][][] meas) {
		double [][][] out = new double [meas.length][num_sub][vel_dim*vel_dim];
		final int nvel = vel_dim * vel_dim;
		final int [] cell_stack = new int [meas.length * nvel];
		final int [] label =      new int [meas.length * nvel];
		final int [] members =    new int [meas.length * nvel];
		for (int s = 0; s < num_sub; s++) {
			// Pass 1: label 4D-connected clusters of survivors, collect member cells
			java.util.Arrays.fill(label, -1);
			java.util.ArrayList<int[]> clusters = new java.util.ArrayList<int[]>();
			for (int cell0 = 0; cell0 < label.length; cell0++) {
				if ((label[cell0] >= 0) || (meas[cell0 / nvel][s][cell0 % nvel] <= 0)) continue;
				int ncl = clusters.size();
				int n_memb = 0;
				int sp = 0;
				cell_stack[sp++] = cell0;
				label[cell0] = ncl;
				while (sp > 0) {
					int cell = cell_stack[--sp];
					members[n_memb++] = cell;
					int p = cell / nvel, v = cell % nvel;
					int px = p % roi_width,  py = p / roi_width;
					int vx = v % vel_dim,    vy = v / vel_dim;
					for (int dpy = -1; dpy <= 1; dpy++) {
						int ny = py + dpy;
						if ((ny < 0) || (ny >= roi_height)) continue;
						for (int dpx = -1; dpx <= 1; dpx++) {
							int nx = px + dpx;
							if ((nx < 0) || (nx >= roi_width)) continue;
							int np = ny * roi_width + nx;
							for (int dvy = -1; dvy <= 1; dvy++) {
								int nvy = vy + dvy;
								if ((nvy < 0) || (nvy >= vel_dim)) continue;
								for (int dvx = -1; dvx <= 1; dvx++) {
									int nvx = vx + dvx;
									if ((nvx < 0) || (nvx >= vel_dim)) continue;
									int ncell = np * nvel + nvy * vel_dim + nvx;
									if ((label[ncell] < 0) && (meas[np][s][nvy * vel_dim + nvx] > 0)) {
										label[ncell] = ncl;
										cell_stack[sp++] = ncell;
									}
								}
							}
						}
					}
				}
				clusters.add(java.util.Arrays.copyOf(members, n_memb));
			}
			// Pass 2: per-cluster reconstruction
			for (int[] cl : clusters) {
				if (cl.length > max_clust) { // weak signal, linear mode - identity pass-through
					for (int cell : cl) {
						out[cell / nvel][s][cell % nvel] += meas[cell / nvel][s][cell % nvel];
					}
					continue;
				}
				// survivor coordinates and values
				int k = cl.length;
				double [][] x = new double [k][4]; // (px, py, vx, vy)
				double []   v = new double [k];
				double v_sum = 0;
				double [] c_sum = new double [4];
				for (int i = 0; i < k; i++) {
					int p =  cl[i] / nvel;
					int vv = cl[i] % nvel;
					x[i][0] = p % roi_width;
					x[i][1] = p / roi_width;
					x[i][2] = vv % vel_dim;
					x[i][3] = vv / vel_dim;
					v[i] = meas[p][s][vv];
					v_sum += v[i];
					for (int d = 0; d < 4; d++) c_sum[d] += v[i] * x[i][d];
				}
				// center: value-weighted centroid rounded to the half-pixel grid
				double [] c = new double [4];
				for (int d = 0; d < 4; d++) c[d] = Math.round(2.0 * c_sum[d] / v_sum) / 2.0;
				// least-squares amplitude + fit acceptance
				boolean fits = true;
				double p_min = Double.MAX_VALUE, p_max = 0;
				double s_vw = 0, s_ww = 0;
				for (int i = 0; i < k; i++) {
					double w = 1.0;
					for (int d = 0; d < 4; d++) w *= cosWeight(x[i][d] - c[d]);
					if (w < W_MIN) { // survivor where the model is ~zero - not this bump
						fits = false;
						break;
					}
					double pi = v[i] / w;
					if (pi < p_min) p_min = pi;
					if (pi > p_max) p_max = pi;
					s_vw += v[i] * w;
					s_ww += w * w;
				}
				if (fits && (k > 1) && (p_max > p_min * fit_rat)) fits = false; // samples disagree with one bump
				if (fits) {
					addBump(out, s, c[0], c[1], c[2], c[3], s_vw / s_ww);
				} else { // degenerate arrangement: per-survivor single-point bumps
					for (int i = 0; i < k; i++) {
						addBump(out, s, x[i][0], x[i][1], x[i][2], x[i][3], v[i]);
					}
				}
			}
		}
		return out;
	}

	/**
	 * Re-fit the merged buffer blobs to canonical half-cosine bumps. // By Claude on 06/10/2026
	 * Peels blobs strongest-first: takes the strongest unclaimed cell M (stops below
	 * rs_min), grows a 4D-connected region over cells above rs_frac*M, computes the
	 * region's total energy E and FRACTIONAL energy centroid c (no half-grid rounding -
	 * rounding hysteresis would block velocity-cell migration), zeroes the claimed
	 * cells, and re-injects a canonical bump at c with peak = E / S(c), where S(c) is
	 * the sum of the bump's in-bounds grid samples - so the injected sparse sampling
	 * totals exactly E at any sub-cell phase (energy neither pumped nor drained by
	 * blob motion). Injection happens after all peeling so re-synthesized energy is
	 * not re-claimed. Sub-threshold residue (blob skirts, weak content) stays in the
	 * buffer untouched.
	 */
	private void resharpen() {
		final int nvel = vel_dim * vel_dim;
		final int [] cell_stack =   new int [buffer.length * nvel];
		final boolean [] claimed =  new boolean [buffer.length * nvel];
		final java.util.ArrayList<double[]> bumps = new java.util.ArrayList<double[]>(); // {cpx, cpy, cvx, cvy, energy}
		for (int s = 0; s < num_sub; s++) {
			java.util.Arrays.fill(claimed, false);
			bumps.clear();
			// Collect peel candidates (>= rs_min) once and sort - replaces the repeated   // By Claude on 06/11/2026
			// full-buffer strongest-cell scans (O(cells*blobs), minutes for a large ROI
			// with many targets) with one pass + sort. Iterating the sorted list and
			// skipping already-claimed cells yields exactly the original peel order:
			// unclaimed cell values never change, so the next unclaimed candidate IS the
			// strongest remaining. Value packed as float bits (monotonic for positives)
			// in the high 32 bits, cell index in the low 32.
			int n_cand = 0;
			for (int p = 0; p < buffer.length; p++) {
				double [] plane = buffer[p][s];
				for (int v = 0; v < nvel; v++) {
					if (plane[v] >= rs_min) n_cand++;
				}
			}
			long [] cand = new long [n_cand];
			n_cand = 0;
			for (int p = 0; p < buffer.length; p++) {
				double [] plane = buffer[p][s];
				int base = p * nvel;
				for (int v = 0; v < nvel; v++) {
					if (plane[v] >= rs_min) {
						cand[n_cand++] = (((long) Float.floatToIntBits((float) plane[v])) << 32) | (base + v);
					}
				}
			}
			java.util.Arrays.sort(cand);
			for (int nc = cand.length - 1; nc >= 0; nc--) { // strongest first
				int cell_m = (int) (cand[nc] & 0xFFFFFFFFL);
				if (claimed[cell_m]) continue; // absorbed by a stronger blob's growth
				double m = buffer[cell_m / nvel][s][cell_m % nvel];
				if (m < rs_min) continue;
				double thresh = rs_frac * m;
				// grow the blob over 4D-connected cells above thresh, accumulate energy and centroid
				int sp = 0;
				cell_stack[sp++] = cell_m;
				claimed[cell_m] = true;
				double e = 0;
				double [] c = new double [4];
				while (sp > 0) {
					int cell = cell_stack[--sp];
					int p = cell / nvel, v = cell % nvel;
					double val = buffer[p][s][v];
					e += val;
					c[0] += val * (p % roi_width);
					c[1] += val * (p / roi_width);
					c[2] += val * (v % vel_dim);
					c[3] += val * (v / vel_dim);
					buffer[p][s][v] = 0; // claimed energy is re-synthesized below
					int px = p % roi_width,  py = p / roi_width;
					int vx = v % vel_dim,    vy = v / vel_dim;
					for (int dpy = -1; dpy <= 1; dpy++) {
						int ny = py + dpy;
						if ((ny < 0) || (ny >= roi_height)) continue;
						for (int dpx = -1; dpx <= 1; dpx++) {
							int nx = px + dpx;
							if ((nx < 0) || (nx >= roi_width)) continue;
							int np = ny * roi_width + nx;
							for (int dvy = -1; dvy <= 1; dvy++) {
								int nvy = vy + dvy;
								if ((nvy < 0) || (nvy >= vel_dim)) continue;
								for (int dvx = -1; dvx <= 1; dvx++) {
									int nvx = vx + dvx;
									if ((nvx < 0) || (nvx >= vel_dim)) continue;
									int ncell = np * nvel + nvy * vel_dim + nvx;
									if (!claimed[ncell] && (buffer[np][s][nvy * vel_dim + nvx] > thresh)) {
										claimed[ncell] = true;
										cell_stack[sp++] = ncell;
									}
								}
							}
						}
					}
				}
				if (e > 0) {
					for (int d = 0; d < 4; d++) c[d] /= e;
					bumps.add(new double[] {c[0], c[1], c[2], c[3], e});
				}
			}
			for (double [] b : bumps) {
				double s_sum = bumpSum(b[0], b[1], b[2], b[3]);
				if (s_sum > 0) {
					addBump(buffer, s, b[0], b[1], b[2], b[3], b[4] / s_sum);
				}
			}
		}
	}

	// Sum of the in-bounds grid samples of a unit-peak half-cosine bump at fractional center c -
	// the normalizer making an injected bump's sparse sampling total a prescribed energy.
	private double bumpSum(double cpx, double cpy, double cvx, double cvy) { // By Claude on 06/10/2026
		int px0 = Math.max((int) Math.ceil (cpx - 1.5), 0);
		int px1 = Math.min((int) Math.floor(cpx + 1.5), roi_width - 1);
		int py0 = Math.max((int) Math.ceil (cpy - 1.5), 0);
		int py1 = Math.min((int) Math.floor(cpy + 1.5), roi_height - 1);
		int vx0 = Math.max((int) Math.ceil (cvx - 1.5), 0);
		int vx1 = Math.min((int) Math.floor(cvx + 1.5), vel_dim - 1);
		int vy0 = Math.max((int) Math.ceil (cvy - 1.5), 0);
		int vy1 = Math.min((int) Math.floor(cvy + 1.5), vel_dim - 1);
		double sum = 0;
		for (int py = py0; py <= py1; py++) {
			double wy = cosWeight(py - cpy);
			if (wy <= 0) continue;
			for (int px = px0; px <= px1; px++) {
				double wxy = wy * cosWeight(px - cpx);
				if (wxy <= 0) continue;
				for (int vy = vy0; vy <= vy1; vy++) {
					double wxyv = wxy * cosWeight(vy - cvy);
					if (wxyv <= 0) continue;
					for (int vx = vx0; vx <= vx1; vx++) {
						double w = wxyv * cosWeight(vx - cvx);
						if (w > 0) sum += w;
					}
				}
			}
		}
		return sum;
	}

	// Inject the grid sampling of one half-cosine bump: out += P * prod_d cos(PI/3*(x_d - c_d))
	// over the support (|x_d - c_d| < 1.5 per dimension), clipped to ROI/velocity bounds.
	private void addBump( // By Claude on 06/10/2026
			double [][][] out,
			int           s,
			double        cpx,
			double        cpy,
			double        cvx,
			double        cvy,
			double        peak) {
		final int nvel = vel_dim * vel_dim;
		int px0 = Math.max((int) Math.ceil (cpx - 1.5), 0);
		int px1 = Math.min((int) Math.floor(cpx + 1.5), roi_width - 1);
		int py0 = Math.max((int) Math.ceil (cpy - 1.5), 0);
		int py1 = Math.min((int) Math.floor(cpy + 1.5), roi_height - 1);
		int vx0 = Math.max((int) Math.ceil (cvx - 1.5), 0);
		int vx1 = Math.min((int) Math.floor(cvx + 1.5), vel_dim - 1);
		int vy0 = Math.max((int) Math.ceil (cvy - 1.5), 0);
		int vy1 = Math.min((int) Math.floor(cvy + 1.5), vel_dim - 1);
		for (int py = py0; py <= py1; py++) {
			double wy = cosWeight(py - cpy);
			if (wy <= 0) continue;
			for (int px = px0; px <= px1; px++) {
				double wxy = wy * cosWeight(px - cpx);
				if (wxy <= 0) continue;
				int p = py * roi_width + px;
				for (int vy = vy0; vy <= vy1; vy++) {
					double wxyv = wxy * cosWeight(vy - cvy);
					if (wxyv <= 0) continue;
					int vbase = vy * vel_dim;
					for (int vx = vx0; vx <= vx1; vx++) {
						double w = wxyv * cosWeight(vx - cvx);
						if (w <= 0) continue;
						out[p][s][vbase + vx] += peak * w;
					}
				}
			}
		}
	}

	// Half-cosine model weight: cos(PI/3 * d) inside the 3-pixel half-period, 0 outside.
	// Grid samples around an on-grid center: 0.5 : 1.0 : 0.5; around a half-grid center:
	// 0.866 : 0.866 (the 1.5-distance samples are exactly 0).
	private static double cosWeight(double d) { // By Claude on 06/10/2026
		double a = Math.abs(d);
		return (a >= 1.5) ? 0.0 : Math.cos(Math.PI / 3.0 * a);
	}
}
