/**
 **
 ** CuasPoseRT.java - RT pose-adjustment prototype: per-scene 3-angle pose
 ** fitting against the persistent virtual-center reference.
 **
 ** Copyright (C) 2026 Elphel, Inc.
 **
 ** -----------------------------------------------------------------------------**
 **
 **  CuasPoseRT.java is free software: you can redistribute it and/or modify
 **  it under the terms of the GNU General Public License as published by
 **  the Free Software Foundation, either version 3 of the License, or
 **  (at your option) any later version.
 **
 **  This program is distributed in the hope that it will be useful,
 **  but WITHOUT ANY WARRANTY; without even the implied warranty of
 **  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 **  GNU General Public License for more details.
 **
 **  You should have received a copy of the GNU General Public License
 **  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 ** -----------------------------------------------------------------------------**
 **
 */
package com.elphel.imagej.cuas.rt;

import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;

import com.elphel.imagej.cameras.CLTParameters;
import com.elphel.imagej.gpu.GpuQuad;
import com.elphel.imagej.gpu.TpTask;
import com.elphel.imagej.gpu.testdata.PoseCorrExport; // pose_corr kernel-test capture // By Claude on 07/13/2026
import com.elphel.imagej.tileprocessor.Correlation2d;
import com.elphel.imagej.tileprocessor.ErsCorrection;
import com.elphel.imagej.tileprocessor.ImageDtt;
import com.elphel.imagej.tileprocessor.Interscene;
import com.elphel.imagej.tileprocessor.IntersceneLma;
import com.elphel.imagej.tileprocessor.OpticalFlow;
import com.elphel.imagej.tileprocessor.QuadCLT;
import com.elphel.imagej.tileprocessor.QuadCLTCPU;
import com.elphel.imagej.tileprocessor.TDCorrTile;
import com.elphel.imagej.tileprocessor.TwoQuadCLT;

import ij.ImagePlus;
import ij.ImageStack;
import ij.Prefs;
import ij.process.FloatProcessor;

/**
 * Phase A of the RT pose-adjustment package (design: ANDREY_CONTINUE 2026-07-03,
 * details in imagej-elphel-internal handoffs). A top-level scene iterator that
 * re-generates the per-scene 3-angle (az, tilt, roll) poses of a CUAS sequence
 * by fitting each scene directly against the persistent VIRTUAL-CENTER reference
 * (center CLT), in ascending time order, seeded by prediction — the RT processing
 * pattern. Uses the proven offline measurement engine
 * (Interscene.adjustPairsLMAInterscene(), which assumes the reference GPU data is
 * set once for all scenes); Phase B will swap that engine for the lean
 * TD-average x virtual-center correlation with the GPU argmax+eigen kernels,
 * keeping this iterator and its comparison output unchanged as the oracle.
 *
 * Differences from the offline setInitialOrientationsCuas():
 *  - ascending scene order (RT: "now" keeps arriving) instead of descending;
 *  - pose seed = previous fitted pose (zero-order prediction; the fit is anchored
 *    to the virtual center, so prediction only warm-starts the LMA and never
 *    accumulates into the geometry) instead of IMS integration;
 *  - single pass with the FINAL combo DSI (no refinement pass needed - the offline
 *    refinement only existed because disparity arrived after initial orientations);
 *  - tile selection by simple strength threshold (curt_pose_str) of the combo DSI
 *    (equivalent to old getReliableTiles: >1.0 gives 1074 vs 1055 tiles on the
 *    reference sequence); the optimized minimal selection is a future slow-loop task.
 *
 * Output: per-scene CSV (predicted/fitted/stored poses + deltas + rms) in the model
 * directory and a console summary of fitted-vs-stored statistics. Stored poses
 * (scenes_poses of the center ErsCorrection, restored from INTERFRAME corr-xml)
 * are the comparison truth - the re-adjusted offline set fitted from the same
 * DSI vintage.
 *
 * By Claude on 07/03/2026, from Andrey's design.
 */
public class CuasPoseRT {
	public static final double [] ZERO3 = {0.0, 0.0, 0.0};
	// Tile-selection CALIBRATION file (name says so - it must be PRESERVED with the model:
	// per-tile max-over-scenes residual, NaN = unusable; reliable_tiles are derived from it).
	// Renamed from -POSE-RT-MAXDXY per Andrey 07/05/2026; old name still read as fallback.
	public static final String TILE_CALIB_SUFFIX =     "-POSE-RT-TILE-CALIB";
	public static final String TILE_CALIB_SUFFIX_OLD = "-POSE-RT-MAXDXY";
	// Per-scene measurement characterization slices of the -POSE-RT-HYPER stack
	// (z = scenes, t = components, matching the renderSceneSequence make_hyper convention).
	// dx,dy,strength from coord_motion[1] (vector_XYS); the eigen four from coord_motion[2]
	// {eig_x,eig_y,l0,l1} (filled when imp.eig_use is on; NaN otherwise): sqrt_l0/sqrt_l1 =
	// correlation-peak ellipse half-axes (pix), elong = sqrt(l1/l0) (large = linear feature,
	// e.g. straight horizon: precise only perpendicular), eig0_ang = direction of the
	// precise axis (eigenvector of the smaller eigenvalue), radians [0,PI).
	public static final String [] HYPER_COMPONENTS =
		{"dx", "dy", "strength", "dxy", "sqrt_l0", "sqrt_l1", "elong", "eig0_ang"};

	// Cluster selection: a 3x3 window is a candidate if at least this many of its tiles
	// pass the outlier gate. Tiles are selected in whole clusters so every selected tile
	// has in-selection NEIGHBORS - the per-scene neighbor consolidation in the correlation
	// processing (min_str_neib/eig_str_neib) starves on scattered tiles (measured on
	// 1773135476_186641: tiles with ~2.7 selected neighbors were measured in >90% of
	// scenes, scattered tiles with ~0.7 neighbors almost never; corr 0.78).
	public static final int CLUSTER_MIN_ELIGIBLE = 6;

	/**
	 * Derive the tile selection from the per-tile max-residual calibration array
	 * (-POSE-RT-TILE-CALIB: max over scenes of measured dxy; NaN = unusable - either not
	 * selected/measured or NaN in at least one scene, always rejected). Stages (all
	 * scale-free, no absolute pixel constants):
	 *  1 - robust outlier gate: keep finite values <= median + k_nmad*NMAD of the
	 *      finite population (the "single high bump + long tail" histogram rule);
	 *  2 - compute budget: pick whole 3x3 CLUSTERS of gate-passing tiles (see
	 *      CLUSTER_MIN_ELIGIBLE) round-robin from three priority pools - LEFTMOST,
	 *      RIGHTMOST (per Andrey: edge tiles have the most influence on roll),
	 *      BEST-QUALITY - until num_tiles is reached. Clusters are disjoint. If
	 *      candidates run out, fill the remainder with best scattered tiles.
	 * By Claude on 07/04/2026, from Andrey's design.
	 */
	public static boolean [] deriveSelection(
			final float []  fmax,
			final int       tilesX,
			final double    k_nmad,      // <=0 - skip the gate
			final int       num_tiles,   // <=0 - no cap
			final int       border_tiles,// interior gate: exclude tiles within this many tiles of the array border, 0 - off // By Claude on 07/13/2026
			final int       debugLevel) {
		final boolean [] sel = new boolean [fmax.length];
		final int tilesY = fmax.length / tilesX;
		// Interior gate (3-A3, Andrey's ruling 07/13/2026, PURE BORDER RING - second
		// ruling same day): exclude tiles within border_tiles of the tile-array border.
		// The OOB-missing band is geometrically confined to the frame border (fitted
		// poses move the grid sub-pixel-to-few-px; margin-dropped centers = 1-2 tile
		// ring; partial 16-sensor OOB soft band 12 px = 1.5 tiles), so a ring of 4
		// covers hard+soft+slack and guarantees all-16-interior tiles at every scene
		// pose - the GPU pose chain can then use a FIXED per-sequence tile list with
		// the consolidation OOB pass bypassed. NO morphological shrink of the finite
		// mask: the calibration is NaN wherever a tile was not STRENGTH-selected (all
		// of the sky + scattered terrain holes, ~21% finite density) - a blind 2-tile
		// shrink annihilated the selection to ~2 tiles (07/13 run, killed).
		final boolean [] usable = new boolean [fmax.length];
		for (int i = 0; i < fmax.length; i++) {
			final int y = i / tilesX, x = i % tilesX;
			usable[i] = !Float.isNaN(fmax[i]) && !Float.isInfinite(fmax[i]) &&
					(y >= border_tiles) && (y < tilesY - border_tiles) &&
					(x >= border_tiles) && (x < tilesX - border_tiles);
		}
		float [] finite = new float [fmax.length];
		int num_finite = 0;
		for (int i = 0; i < fmax.length; i++) if (usable[i]) finite[num_finite++] = fmax[i];
		if (num_finite == 0) return sel;
		finite = Arrays.copyOf(finite, num_finite);
		Arrays.sort(finite);
		final double median = finite[num_finite/2];
		final float [] adev = new float [num_finite];
		for (int i = 0; i < num_finite; i++) adev[i] = (float) Math.abs(finite[i] - median);
		Arrays.sort(adev);
		final double nmad = 1.4826 * adev[num_finite/2];
		final double gate = (k_nmad > 0) ? (median + k_nmad * nmad) : Double.POSITIVE_INFINITY;
		final boolean [] eligible = new boolean [fmax.length];
		int num_gated = 0;
		for (int i = 0; i < fmax.length; i++) {
			eligible[i] = usable[i] && (fmax[i] <= gate); // usable folds in the interior border ring // By Claude on 07/13/2026
			if (eligible[i]) num_gated++;
		}
		if (debugLevel > -4) {
			System.out.println(String.format(
					"CuasPoseRT.deriveSelection(): usable=%d (border ring %d), median=%.3f, NMAD=%.3f, gate=%.3f (k=%.2f) -> %d gated tiles",
					num_finite, border_tiles, median, nmad, gate, k_nmad, num_gated));
		}
		if ((num_tiles <= 0) || (num_gated <= num_tiles)) {
			System.arraycopy(eligible, 0, sel, 0, sel.length);
			return sel;
		}
		// ---- Stage 2: disjoint 3x3 clusters, round-robin left / right / best-quality ----
		final ArrayList<int []> cand = new ArrayList<int []>(); // {center_index, n_eligible}
		final float [] cquality = new float [fmax.length];      // median fmax of eligible members
		for (int cy = 1; cy < tilesY - 1; cy++) {
			for (int cx = 1; cx < tilesX - 1; cx++) {
				final float [] q = new float [9];
				int n = 0;
				for (int dy = -1; dy <= 1; dy++) for (int dx = -1; dx <= 1; dx++) {
					final int i = (cy + dy) * tilesX + (cx + dx);
					if (eligible[i]) q[n++] = fmax[i];
				}
				if (n >= CLUSTER_MIN_ELIGIBLE) {
					final int ci = cy * tilesX + cx;
					Arrays.sort(q, 0, n);
					cquality[ci] = q[n/2];
					cand.add(new int [] {ci, n});
				}
			}
		}
		// three priority orders over the same candidates
		final ArrayList<int []> by_left =    new ArrayList<int []>(cand);
		final ArrayList<int []> by_right =   new ArrayList<int []>(cand);
		final ArrayList<int []> by_quality = new ArrayList<int []>(cand);
		by_left.sort   ((a,b) -> Integer.compare(a[0] % tilesX, b[0] % tilesX));
		by_right.sort  ((a,b) -> Integer.compare(b[0] % tilesX, a[0] % tilesX));
		by_quality.sort((a,b) -> Float.compare(cquality[a[0]], cquality[b[0]]));
		final ArrayList<ArrayList<int []>> pools = new ArrayList<ArrayList<int []>>();
		pools.add(by_left); pools.add(by_right); pools.add(by_quality);
		final int [] pool_pos = new int [pools.size()];
		final int [] pool_used = new int [pools.size()];
		int num_sel = 0, num_clusters = 0;
		while (num_sel < num_tiles) {
			boolean any = false;
			for (int p = 0; (p < pools.size()) && (num_sel < num_tiles); p++) {
				final ArrayList<int []> pool = pools.get(p);
				while (pool_pos[p] < pool.size()) {
					final int ci = pool.get(pool_pos[p]++)[0];
					final int cy = ci / tilesX, cx = ci % tilesX;
					boolean overlap = false;
					for (int dy = -1; (dy <= 1) && !overlap; dy++) for (int dx = -1; dx <= 1; dx++) {
						if (sel[(cy + dy) * tilesX + (cx + dx)]) { overlap = true; break; }
					}
					if (overlap) continue;
					for (int dy = -1; dy <= 1; dy++) for (int dx = -1; dx <= 1; dx++) {
						final int i = (cy + dy) * tilesX + (cx + dx);
						if (eligible[i] && !sel[i] && (num_sel < num_tiles)) {
							sel[i] = true;
							num_sel++;
						}
					}
					num_clusters++;
					pool_used[p]++;
					any = true;
					break;
				}
			}
			if (!any) break; // all pools exhausted
		}
		if (num_sel < num_tiles) { // fill the remainder with best scattered eligible tiles
			final int [] idx = new int [num_gated];
			int n = 0;
			for (int i = 0; i < fmax.length; i++) if (eligible[i] && !sel[i]) idx[n++] = i;
			final Integer [] order = new Integer [n];
			for (int j = 0; j < n; j++) order[j] = j;
			final int [] fidx = Arrays.copyOf(idx, n);
			Arrays.sort(order, (a,b) -> Float.compare(fmax[fidx[a]], fmax[fidx[b]]));
			for (int j = 0; (j < n) && (num_sel < num_tiles); j++) {
				sel[fidx[order[j]]] = true;
				num_sel++;
			}
		}
		if (debugLevel > -4) {
			float worst = 0;
			for (int i = 0; i < fmax.length; i++) if (sel[i] && (fmax[i] > worst)) worst = fmax[i];
			System.out.println(String.format(
					"CuasPoseRT.deriveSelection(): %d clusters (left/right/quality = %d/%d/%d) -> %d tiles, worst kept max=%.3f pix",
					num_clusters, pool_used[0], pool_used[1], pool_used[2], num_sel, worst));
		}
		return sel;
	}

	/**
	 * Phase B lean measurement: ONE scene-vs-virtual-center correlation per LMA cycle.
	 * Average the 16 sensors in TD BEFORE correlation ("multiply averages, not average
	 * products"), then a single conj-multiply against the persistent center TD:
	 *   interCorrTD(sensor_mask=0)  - tasks(pose) + offsets + convert_direct ONLY
	 *   CuasTD.consolidateToSlot0  - the consolidation bridge: GPU v1 chain on the JNA
	 *       backend (on-device, OOB soft/hard filtered), CPU bridge (getCltData ->
	 *       consolidateSensorsTD -> setCltData(0)) on JCuda // By Claude on 07/12/2026
	 *   setSensorMaskInter(1) + execCorr2D_inter_TD  - the single conj-multiply
	 *   TDCorrTile.getFromGpu -> TDCorrTile.convertTDtoPD  - FZ-normalize + PD (existing)
	 *   Correlation2d.getMaxXYCmEig  - peak + eigen (the GPU argmax kernel's oracle)
	 * All GPU stages are existing kernels (the correlate/normalize/PD trio is the
	 * JNA-validated CuasMotion.correlatePairs path). Correlation stages are geometry-
	 * blind: all projection/distortion is baked into the tasks (average-camera grid), so
	 * the measured (dx,dy) is displacement on the same grid the pose Jacobian uses.
	 * Differences from the oracle engine (adjustPairsLMAInterscene) in this v1: no FPN
	 * peak masking (input is FPN-subtracted by conditioning), no moving-object filter,
	 * min_confidence not applied (tiles are pre-calibrated).
	 * By Claude on 07/05/2026, from Andrey's design.
	 * @param corr_pd_out null or length-1 holder - filled with the pixel-domain
	 *        correlation tiles ([tile][15*15], dense grid index) for debug rendering
	 * @param img_out null or length-1 holder - filled with the imclt render of the
	 *        consolidated composite scene ([width*height], NaN outside task tiles);
	 *        requesting it adds an erased re-convert + an imclt pass (debug only)
	 * @param mb_vectors null (no motion-blur compensation) or [2][ntiles] blur vectors -
	 *        the certified-chain MB: two task sets, double convert (positive SET +
	 *        negative shifted SUBTRACT), erase-first so undefined tiles stay NaN
	 * @param mb_tau bolometer time constant, s (imp.mb_tau)
	 * @param mb_max_gain max MB scale before offset stretching (imp.mb_max_gain_inter)
	 * @return [3][num_pix][] {centers pXpYD, vector_XYS {dx,dy,strength}, eigen
	 *         {e0x,e0y,l0,l1}} - the coord_motion contract of interCorrPair - or null
	 */
	public static double [][][] leanMeasure(
			final CLTParameters clt_parameters,
			final ImageDtt      image_dtt,
			final QuadCLT       center_CLT,
			final QuadCLT       scene,
			final double []     center_disparity,
			final double [][]   pXpYD_center,
			final boolean []    selection,
			final double []     scene_xyz,
			final double []     scene_atr,
			final int           margin,
			final double [][]   mb_vectors,  // By Claude on 07/05/2026
			final double        mb_tau,      // By Claude on 07/05/2026
			final double        mb_max_gain, // By Claude on 07/05/2026
			final double [][][] corr_pd_out, // By Claude on 07/04/2026
			final float [][]    img_out,     // By Claude on 07/04/2026
			final int           debugLevel) {
		final GpuQuad gpuQuad = center_CLT.getGPUQuad();
		final int num_pix = pXpYD_center.length;
		// 1. scene grid at the current pose estimate (average-camera coordinates)
		final double [][] scene_pXpYD = OpticalFlow.transformToScenePxPyD(
				null, center_disparity, scene_xyz, scene_atr, scene, center_CLT);
		final double disparity_corr = clt_parameters.imp.disparity_corr + center_CLT.getDispInfinityRef();
		final TpTask [][] cons_tasks; // the convert task sets, hoisted for the consolidation OOB filter // By Claude on 07/12/2026
		if (mb_vectors != null) {
			// Motion-blur compensation (lean v2, the certified CuasRender chain): two task
			// sets, double convert (positive SET + negative shifted SUBTRACT), erase-first
			// so undefined tiles reach downstream (consolidation, renders) as NaN.
			// By Claude on 07/05/2026.
			final TpTask [][] tp_tasks2 = GpuQuad.setInterTasksMotionBlur(
					scene.getNumSensors(),
					scene.getGeometryCorrection().getSensorWH()[0],
					false,                      // GPU calculates port coordinates
					scene_pXpYD,
					selection,                  // only the calibrated tile selection
					mb_tau,
					mb_max_gain,
					mb_vectors,
					scene.getGeometryCorrection(),
					disparity_corr,
					margin,
					null,                       // valid_tiles
					ImageDtt.THREADS_MAX);
			if ((tp_tasks2 == null) || (tp_tasks2[0] == null) || (tp_tasks2[0].length == 0)) {
				System.out.println("leanMeasure(): no MB tasks for scene "+scene.getImageName());
				return null;
			}
			sortTasksByTile(tp_tasks2[0]); // fixed task-stream order (3-A3, GPU static shape) // By Claude on 07/13/2026
			sortTasksByTile(tp_tasks2[1]);
			cons_tasks = tp_tasks2; // By Claude on 07/12/2026
			// pose_corr kernel-test capture (checkpoint 1, roadmap 3-C rung 1): the task
			// streams exactly as handed to the GPU, BEFORE setTasks/updateTasks mutate them.
			// No-op unless the curt.kernel_test=pose_corr run armed it. By Claude on 07/13/2026.
			PoseCorrExport.iterTasksPre(clt_parameters, image_dtt, gpuQuad, scene, cons_tasks);
			image_dtt.interCorrTDMotionBlur(
					clt_parameters.img_dtt,
					1,                          // erase_clt_first: NaN outside the task set
					tp_tasks2,
					null,                       // fcorr_td - not used with mask=0
					clt_parameters.gpu_sigma_r,
					clt_parameters.gpu_sigma_b,
					clt_parameters.gpu_sigma_g,
					clt_parameters.gpu_sigma_m,
					scene.isMonochrome() ? 1.0 : clt_parameters.gpu_sigma_rb_corr,
					clt_parameters.getGpuCorrSigma(scene.isMonochrome()),
					clt_parameters.getGpuCorrLoGSigma(scene.isMonochrome()),
					clt_parameters.corr_red,
					clt_parameters.corr_blue,
					0,                          // sensor_mask_inter = 0: convert only
					ImageDtt.THREADS_MAX,
					debugLevel);
		} else {
			final TpTask [] tp_tasks = GpuQuad.setInterTasks(
					scene.getNumSensors(),
					scene.getGeometryCorrection().getSensorWH()[0],
					false,                          // GPU calculates port coordinates
					scene_pXpYD,                    // per-tile pX,pY,disparity
					selection,                      // only the calibrated tile selection
					scene.getGeometryCorrection(),
					disparity_corr,
					margin,
					null,                           // valid_tiles (not needed)
					ImageDtt.THREADS_MAX);
			if ((tp_tasks == null) || (tp_tasks.length == 0)) {
				System.out.println("leanMeasure(): no tasks for scene "+scene.getImageName());
				return null;
			}
			sortTasksByTile(tp_tasks); // fixed task-stream order (3-A3, GPU static shape) // By Claude on 07/13/2026
			cons_tasks = new TpTask [][] {tp_tasks, null}; // no-MB: single set, [1] = null // By Claude on 07/12/2026
			PoseCorrExport.iterTasksPre(clt_parameters, image_dtt, gpuQuad, scene, cons_tasks); // pose_corr capture (armed only) // By Claude on 07/13/2026
			// 2. convert only: LPFs + tasks + offsets + convert_direct, NO correlation (mask=0)
			image_dtt.interCorrTD(
					clt_parameters.img_dtt,
					tp_tasks,
					null,                           // fcorr_td - not used with mask=0
					clt_parameters.gpu_sigma_r,
					clt_parameters.gpu_sigma_b,
					clt_parameters.gpu_sigma_g,
					clt_parameters.gpu_sigma_m,
					scene.isMonochrome() ? 1.0 : clt_parameters.gpu_sigma_rb_corr,
					clt_parameters.getGpuCorrSigma(scene.isMonochrome()),
					clt_parameters.getGpuCorrLoGSigma(scene.isMonochrome()),
					clt_parameters.corr_red,
					clt_parameters.corr_blue,
					0,                              // sensor_mask_inter = 0: convert only, return before correlate
					ImageDtt.THREADS_MAX,
					debugLevel);
			// DEBUG (img_out): re-convert with erase_clt=1 so non-task tiles are NaN (interCorrTD
			// converts with erase=-1, leaving the PREVIOUS scene's tiles outside the selection -
			// harmless for correlation, ghosts in a render). Same LPFs/tasks/offsets - identical
			// task-tile content, only the background differs. By Claude on 07/04/2026.
			if (img_out != null) {
				gpuQuad.execConvertDirect(false, null, 1);
			}
		}
		// 3. the consolidation bridge: GPU v1 chain (JNA backend, on-device, OOB-filtered)
		// with the CPU bridge as the JCuda-backend fallback - see CuasTD.consolidateToSlot0.
		// By Claude on 07/12/2026 (was the unconditional CPU bridge).
		// The TRUE weighted average is fed to the correlator (NOT scaled to the oracle's
		// 16-pair sum): the sum-scaling experiment (LEAN-07) made peaks WIDER (sqrt_l0
		// 3.22 -> 5.58, corr RMS 0.82 -> 1.005) - at 16x signal-to-fat-zero the normalize
		// amplifies the blur-decorrelated high frequencies. Peak width here is BLUR-
		// dominated, not fz-dominated (NOMB oracle 3.58 ~ lean 3.22 at 16x different
		// amplitude); the fat zero regularizes usefully at the average's operating point.
		// By Claude on 07/04/2026, falsified-and-reverted per LEAN-07.
		// pose_corr capture: the SAME arrays after updateTasks() filled the GPU-computed
		// offsets - the exact stream the consolidation flattens. By Claude on 07/13/2026.
		PoseCorrExport.iterTasksPost(scene, cons_tasks);
		CuasTD.consolidateToSlot0(gpuQuad, cons_tasks, // By Claude on 07/12/2026
				clt_parameters.curt.oob_soft, clt_parameters.curt.oob_hard);
		// DEBUG (img_out): render the composite the correlator sees (the weighted average)
		if (img_out != null) {
			img_out[0] = com.elphel.imagej.cuas.CuasMotion.perSensorImagesFromTD(gpuQuad, false)[0];
		}
		// 4. single conj-multiply: only sensor 0 (holding the average) vs the center ref
		final double [] col_weights = scene.isMonochrome() ?
				(new double [] {1.0}) :
				(new double [] {clt_parameters.corr_red/(1.0+clt_parameters.corr_red+clt_parameters.corr_blue),
						clt_parameters.corr_blue/(1.0+clt_parameters.corr_red+clt_parameters.corr_blue),
						1.0/(1.0+clt_parameters.corr_red+clt_parameters.corr_blue)});
		gpuQuad.setSensorMaskInter(1);
		gpuQuad.execCorr2D_inter_TD(col_weights);
		// pose_corr capture: checkpoint 1 = the raw TD correlation + packed indices right
		// here, BEFORE fat-zero normalization (rung 2's boundary). By Claude on 07/13/2026.
		PoseCorrExport.iterCorr(gpuQuad, scene, col_weights);
		// 5. FZ-normalize IN PLACE (rung 2 first half, 07/13/2026): corr2D_normalize runs on
		// the TD buffer where execCorr2D_inter_TD left it - the old getFromGpu/convertTDtoPD
		// TD D2H -> repack -> H2D round trip is gone (PD result bit-identical: per-tile
		// independent kernel, uniform weight 1.0 == null weights - see convertTDtoPDInPlace).
		// The PD D2H below is the LAST remaining bulk transfer - it goes away with the
		// rung-2 peak kernel. By Claude on 07/13/2026 (was the TDCorrTile round-trip path).
		final double [][] corr_pd = TDCorrTile.convertTDtoPDInPlace(
				gpuQuad,
				clt_parameters.curt.fz_inter, // RT-decoupled INTER fat zero (was erroneously the INTRA getGpuFatZero) // By Claude on 07/05/2026
				debugLevel);
		if (corr_pd == null) {
			System.out.println("leanMeasure(): convertTDtoPD returned null for scene "+scene.getImageName());
			return null;
		}
		// pose_corr capture: checkpoint 2 = the FZ-normalized PD (in-place-normalize tol-0
		// target) + the frozen 15-arg peak-contract oracle on every PD tile. Armed-only no-op.
		// By Claude on 07/13/2026.
		PoseCorrExport.iterPd(clt_parameters, gpuQuad, scene, corr_pd);
		if (corr_pd_out != null) corr_pd_out[0] = corr_pd; // debug rendering (-POSE-RT-CORR2D) // By Claude on 07/04/2026
		// (img_out is rendered in step 3, BEFORE the sum scaling - the true weighted average) // By Claude on 07/04/2026
		// 6. peak + eigen per tile (the GPU argmax+eigen kernel's Java oracle).
		// FROZEN RUNG-2 GPU CONTRACT (Andrey's ruling 07/13/2026): the 15-arg
		// getMaxXYCmEig superset - closed-form 2x2 eigen (getEigen2x2, the GPU-portable
		// path) + recentering as a RUNTIME parameter (curt.pose_recenter, 0 = the
		// validated single-pass baseline) + optional envelope de-bias as a precomputed
		// multiplicative array (curt.pose_debias, 0 = off; the kernel takes the array
		// pointer, NULL = off). Oracle and kernel share this exact shape so the
		// checkpoint-2 comparison is contract-vs-contract. By Claude on 07/13/2026.
		final int corr_width = 2 * GPUTileProcessorDttSize() - 1;
		final double [] debias = (clt_parameters.curt.pose_debias > 0) ?
				windowAutocorrDebias(GPUTileProcessorDttSize(), clt_parameters.curt.pose_debias) : null;
		final double [][] centers =    new double [num_pix][];
		final double [][] vector_XYS = new double [num_pix][];
		final double [][] eigen =      new double [num_pix][];
		int num_meas = 0;
		for (int i = 0; i < num_pix; i++) {
			if ((i >= corr_pd.length) || (corr_pd[i] == null) || (pXpYD_center[i] == null)) continue;
			double [] corr_tile = corr_pd[i]; // not modified by getMaxXYCmEig (fpn_mask == null)
			if (debias != null) { // per-tile copy: corr_pd stays raw for -POSE-RT-CORR2D // By Claude on 07/13/2026
				corr_tile = new double [corr_pd[i].length];
				for (int k = 0; k < corr_tile.length; k++) {
					corr_tile[k] = corr_pd[i][k] * debias[k];
				}
			}
			final double [] r = Correlation2d.getMaxXYCmEig(
					corr_tile,
					corr_width,
					clt_parameters.imp.eig_min_abs,   // abs_min
					clt_parameters.imp.eig_min_rel,   // rel_min
					clt_parameters.imp.min_str_sum,   // min_peak (single channel = the "sum")
					clt_parameters.imp.eig_sub_frac,  // pedestal suppression - was hardcoded 0.0: pedestal-inflated
					                                  // eigen lambdas -> sqrt(l) hits eig_max_sqrt -> setEigenTransform
					                                  // k=max(0,1/sqrt(l)-1/max) ZEROES wide-peak tiles (the eigen-ON
					                                  // 3x slow convergence). By Claude on 07/12/2026, Andrey's go.
					clt_parameters.curt.pose_recenter,// recentering passes (0 = baseline single-pass) // By Claude on 07/13/2026
					clt_parameters.imp.eig_sub_frac1, // pedestal suppression during recentering
					clt_parameters.imp.eig_scale_axes,// recenter window: scale eigen-ellipse half-axes
					clt_parameters.imp.eig_inc_axes,  // recenter window: add to half-axes, pix
					null,                             // fpn_mask (input is FPN-subtracted)
					false,                            // ignore_border
					null,                             // debug_data
					true,                             // eig_fast2x2 FROZEN true: the closed-form eigen IS the contract
					false);                           // debug
			if (r == null) continue;
			centers[i] =    pXpYD_center[i];
			vector_XYS[i] = new double [] {r[0], r[1], r[2]};
			eigen[i] =      new double [] {r[3], r[4], r[5], r[6]};
			num_meas++;
		}
		if (debugLevel > -2) {
			System.out.println("leanMeasure(): scene "+scene.getImageName()+" measured "+num_meas+" tiles");
		}
		if (num_meas == 0) return null;
		return new double [][][] {centers, vector_XYS, eigen};
	}

	private static int GPUTileProcessorDttSize() {
		return com.elphel.imagej.gpu.GPUTileProcessor.DTT_SIZE;
	}

	/**
	 * Precomputed envelope de-bias array for the lean peak measurement (rung-2 optional
	 * hook, Andrey's ruling 07/13/2026 - default OFF). The pixel-domain correlation of two
	 * MCLT-windowed tiles is modulated by the window AUTOCORRELATION envelope (separable,
	 * peaked at center): a displaced peak rides an envelope that decays away from center,
	 * so its centroid is pulled back toward zero - the CM argmax under-reports offsets by
	 * ~0.75-0.9x (probed 07/13; the estimator itself is unbiased, the envelope is the bias).
	 * De-bias = multiply the correlation by envelope^-frac before peak extraction.
	 * The GPU kernel takes the SAME array as an optional pointer (NULL = off); this is the
	 * single source of the array for the Java oracle, the export case, and the JNA host.
	 * @param transform_size DTT size (8): window = sin(pi/(2*ts)*(i+0.5)), 2*ts samples
	 * @param frac exponent fraction: 0 = all-ones (no-op), 1 = full envelope divide
	 * @return [(2*ts-1)^2] multiplicative de-bias, 1.0 at the center pixel
	 * By Claude on 07/13/2026.
	 */
	public static double [] windowAutocorrDebias(final int transform_size, final double frac) {
		final int wnd_len = 2 * transform_size;             // MCLT window length (16)
		final int corr_width = 2 * transform_size - 1;      // correlation tile side (15)
		final double [] wnd = new double [wnd_len];
		for (int i = 0; i < wnd_len; i++) {
			wnd[i] = Math.sin(Math.PI * (i + 0.5) / wnd_len);
		}
		final double [] acorr = new double [corr_width];    // 1D autocorr, index d+ (ts-1), d = -(ts-1)..(ts-1)
		for (int d = -(transform_size - 1); d <= (transform_size - 1); d++) {
			double s = 0.0;
			for (int i = Math.max(0, -d); i < Math.min(wnd_len, wnd_len - d); i++) {
				s += wnd[i] * wnd[i + d];
			}
			acorr[d + transform_size - 1] = s;
		}
		final double center = acorr[transform_size - 1];    // normalize so the center is 1.0
		final double [] debias = new double [corr_width * corr_width];
		for (int iy = 0; iy < corr_width; iy++) {
			for (int ix = 0; ix < corr_width; ix++) {
				final double env = (acorr[iy] / center) * (acorr[ix] / center); // separable 2D envelope
				debias[iy * corr_width + ix] = Math.pow(env, -frac);
			}
		}
		return debias;
	}

	/**
	 * Deterministic task-stream order (3-A3, GPU static shape): the setInterTasks*
	 * builders fill their output from worker threads (aTiles.getAndIncrement()), so the
	 * tile ORDER varies run-to-run and scene-to-scene even for identical selections.
	 * Sort ascending by (tileY, tileX): with the interior-shrunk fixed selection the
	 * pose chain then produces the SAME task stream every scene/iteration - the fixed
	 * per-sequence indices contract the GPU consolidation list and the pose_corr
	 * checkpoint test rely on. Correlation output stays index-addressed (packed
	 * tile+pair), so Java-side consumers are unaffected. By Claude on 07/13/2026.
	 */
	private static void sortTasksByTile(final TpTask [] tasks) {
		if (tasks == null) return;
		Arrays.sort(tasks, (a,b) -> (a.getTileY() != b.getTileY()) ?
				Integer.compare(a.getTileY(), b.getTileY()) :
				Integer.compare(a.getTileX(), b.getTileX()));
	}

	/**
	 * Lean v2 CUAS motion blur (Andrey's ruling 07/12/2026): ONE uniform blur vector for
	 * the whole scene, shared by all tiles. Under constant-rpm rotation the image flow is
	 * uniform across the frame up to the roll flow component (roll rate << azimuth/tilt
	 * rates) and the wobble contribution (second-degree) - both ignored by ruling.
	 * Evaluates the SAME Jacobian-times-rates product as the per-tile
	 * OpticalFlow.getMotionBlur at ONE representative point - the DEFINED reference tile
	 * nearest the frame center (a real tile provably valid in the per-tile path; a
	 * synthetic frame-center point with averaged disparity silently failed getMotionBlur's
	 * gates in the 07/12 run) - identical sign/scale conventions to the per-tile oracle -
	 * then broadcasts. Rates source unchanged (finite differences of predicted poses).
	 * The GPU migration consumes this as two scalars per scene: the MB TpTask pair
	 * differs from the main task set by a single shared pixel shift.
	 * By Claude on 07/12/2026, from Andrey's design.
	 * @return [2][pXpYD_center.length] uniform blur vectors (px/s), or null when the
	 *         representative point is degenerate (caller falls back to the per-tile path).
	 */
	public static double [][] uniformMotionBlur(
			final QuadCLT     center_CLT,
			final QuadCLT     scene,
			final double [][] pXpYD_center,
			final double []   camera_xyz,
			final double []   camera_atr,
			final double []   camera_xyz_dt,
			final double []   camera_atr_dt,
			final int         debugLevel) {
		final int [] wh = center_CLT.getGeometryCorrection().getSensorWH();
		// representative = the defined tile nearest the frame center that passes
		// getMotionBlur's own gates (in-frame; disparity within its local
		// min_disparity=-0.5 .. max_disparity=100.0) - mirrored here because a
		// gate-rejected tile fails SILENTLY (NaN result). By Claude on 07/12/2026.
		final double cx = 0.5 * wh[0], cy = 0.5 * wh[1];
		double best_r2 = Double.MAX_VALUE;
		double [] best = null;
		for (double [] p : pXpYD_center) {
			if ((p != null) &&
					!Double.isNaN(p[0]) && (p[0] >= 0) && (p[0] < wh[0]) &&
					!Double.isNaN(p[1]) && (p[1] >= 0) && (p[1] < wh[1]) &&
					!Double.isNaN(p[2]) && (p[2] >= -0.5) && (p[2] < 100.0)) {
				final double r2 = (p[0]-cx)*(p[0]-cx) + (p[1]-cy)*(p[1]-cy);
				if (r2 < best_r2) {
					best_r2 = r2;
					best = p;
				}
			}
		}
		if (best == null) {
			System.out.println("uniformMotionBlur(): no defined in-gate reference tile at all ("+
					pXpYD_center.length+" tiles) - degenerate");
			return null;
		}
		final double [][] one_point = {{best[0], best[1], best[2]}};
		final double [][] mb1 = OpticalFlow.getMotionBlur(
				center_CLT,     // QuadCLT     ref_scene
				scene,          // QuadCLT     scene
				one_point,      // double [][] ref_pXpYD - the single representative point
				camera_xyz,     // double []   camera_xyz
				camera_atr,     // double []   camera_atr
				camera_xyz_dt,  // double []   camera_xyz_dt
				camera_atr_dt,  // double []   camera_atr_dt
				0,              // int         shrink_gaps - nothing to fill
				debugLevel);    // int         debug_level
		if ((mb1 == null) || Double.isNaN(mb1[0][0]) || Double.isNaN(mb1[1][0])) {
			// self-explaining fallback: the point PASSED the mirrored gates, so the NaN
			// came from getMotionBlur's remaining silent path (getDPxSceneDParameters /
			// projection math). Report everything that was tried. By Claude on 07/12/2026.
			System.out.println(String.format(
					"uniformMotionBlur(): degenerate at representative point pXpYD=(%.2f, %.2f, %.4f)"+
					" (r=%.1f px from frame center, passed gates), atr_dt=(%.3g, %.3g, %.3g),"+
					" xyz_dt=(%.3g, %.3g, %.3g) - silent failure inside getMotionBlur",
					one_point[0][0], one_point[0][1], one_point[0][2], Math.sqrt(best_r2),
					camera_atr_dt[0], camera_atr_dt[1], camera_atr_dt[2],
					camera_xyz_dt[0], camera_xyz_dt[1], camera_xyz_dt[2]));
			return null;
		}
		final double [][] mb_vectors = new double [2][pXpYD_center.length];
		Arrays.fill(mb_vectors[0], mb1[0][0]);
		Arrays.fill(mb_vectors[1], mb1[1][0]);
		return mb_vectors;
	}

	/**
	 * Phase B lean per-scene fit: the measure<->solve cycle with leanMeasure() as the
	 * measurement engine and the same IntersceneLma solver/exit rules as the oracle
	 * (adjustPairsLMAInterscene). NOTE v1: NO motion-blur compensation in the lean path
	 * yet - expect the known v*tau bias vs stored (compare against the NOMB baseline,
	 * not MBEN). By Claude on 07/05/2026.
	 * @return fitted {xyz,atr} or null (measurement/LMA failure); fills lma_rms
	 *         ([0] rms, [2] sum weight, [3] tiles) and coord_motion_rslt (for -POSE-RT-HYPER)
	 */
	public static double [][] leanFitScene(
			final CLTParameters clt_parameters,
			final ImageDtt      image_dtt,
			final QuadCLT       center_CLT,
			final QuadCLT       scene,
			final double []     center_disparity,
			final double [][]   pXpYD_center,
			final boolean []    selection,
			final double [][]   predicted,      // initial + pull
			final boolean []    param_select,
			final double []     reg_weights,
			final int           margin,
			final double [][]   mb_vectors,     // null - no MB compensation // By Claude on 07/05/2026
			final double        mb_tau,         // By Claude on 07/05/2026
			final double        mb_max_gain,    // By Claude on 07/05/2026
			final double []     lma_rms,        // out
			final double [][][] coord_motion_rslt, // out [3][][]
			final double [][][] corr_pd_out,    // null or len-1: last-cycle PD correlations // By Claude on 07/04/2026
			final float [][]    img_out,        // null or len-1: last-cycle composite render // By Claude on 07/04/2026
			final int []        ncyc_out,       // null or [1]: outer cycles executed (for the one-line scene report) // By Claude on 07/13/2026
			final int           debugLevel) {
		final IntersceneLma intersceneLma = new IntersceneLma(
				clt_parameters.ilp.ilma_thread_invariant,
				0.0); // no disparity weight (2D only)
		double [][] scene_xyzatr0 = new double [][] {predicted[0].clone(), predicted[1].clone()};
		double [][][] cm = null;
		// lean v2 (Andrey's ruling 07/12/2026): pose_cycles > 0 = run EXACTLY that many
		// measure<->solve cycles, no convergence-based early exit (CUAS scenes are
		// near-identical - the needed count is a constant of the setup; a fixed count
		// gives the GPU pipeline a static launch shape). The convergence delta is still
		// computed below as a QC diagnostic. 0 = legacy convergence exit (flying-camera/
		// varying-scene applications). By Claude on 07/12/2026.
		final int fixed_cycles = clt_parameters.curt.pose_cycles;
		final int max_cycles = (fixed_cycles > 0) ? fixed_cycles : clt_parameters.imp.max_cycles;
		double last_diff_atr = Double.NaN; // By Claude on 07/12/2026
		final double [] cycle_datr = new double [max_cycles]; // per-cycle ATR-change trace (convergence trajectory) // By Claude on 07/12/2026
		final int [] cycle_inner = new int [max_cycles]; // inner runLma iterations per outer cycle // By Claude on 07/12/2026
		// Chain-rule diagnostic (Andrey 07/12/2026): per cycle, the RE-MEASURED pure RMS
		// (after re-correlation, from prepareLMA) vs the MODEL-PREDICTED pure RMS after
		// the solve (from runLma). Jacobian/chain-rule error signature: solved_n low but
		// meas_{n+1} bounces back up (the model overpromised, re-correlation "takes it
		// back"). Healthy damping signature: meas_{n+1} ~ solved_n, both creep down.
		final double [] cycle_rms_meas   = new double [max_cycles]; // By Claude on 07/12/2026
		final double [] cycle_rms_solved = new double [max_cycles]; // By Claude on 07/12/2026
		int ncyc = 0; // cycles actually executed // By Claude on 07/12/2026
		// lean v2 (Andrey 07/12/2026): the inner LMA restarts at ilp.ilma_lambda (0.1) every
		// outer cycle; combined with the 0.1%-improvement inner exit this under-relaxes the
		// outer loop (~0.8/cycle decay, 6-13 cycles observed). pose_lambda > 0 overrides
		// with a near-Gauss-Newton start for the near-linear 3-angle problem.
		final double lma_lambda = (clt_parameters.curt.pose_lambda > 0) ?
				clt_parameters.curt.pose_lambda : clt_parameters.ilp.ilma_lambda; // By Claude on 07/12/2026
		int nlma = 0;
		for (; nlma < max_cycles; nlma++) {
			cm = leanMeasure(
					clt_parameters, image_dtt, center_CLT, scene,
					center_disparity, pXpYD_center, selection,
					scene_xyzatr0[0], scene_xyzatr0[1], margin,
					mb_vectors, mb_tau, mb_max_gain, corr_pd_out, img_out, debugLevel);
			if (cm == null) {
				PoseCorrExport.sceneFailed(scene); // drop a partial pose_corr capture (armed only) // By Claude on 07/13/2026
				return null;
			}
			final double [][] eigen_masked = clt_parameters.imp.eig_xy_lma ? null : cm[2];
			intersceneLma.prepareLMA(
					scene_xyzatr0,                    // scene_xyzatr0
					predicted,                        // scene_xyz_pull
					new double [2][3],                // ref_xyzatr
					scene,                            // scene_QuadClt
					center_CLT,                       // reference_QuadClt
					param_select,                     // param_select
					reg_weights,                      // param_regweights
					clt_parameters.imp.eig_max_sqrt,  // eig_max_sqrt (line = infinite feature)
					clt_parameters.imp.eig_min_sqrt,  // eig_min_sqrt
					eigen_masked,                     // eigen
					cm[1],                            // vector_XYS
					0.0,                              // min_confidence (tiles pre-calibrated)
					null,                             // reliable_ref (already selected)
					cm[0],                            // centers
					false,                            // same_weights
					(nlma == 0),                      // first_run
					null,                             // dbg_prefix
					clt_parameters.imp.debug_level);  // debug_level
			cycle_rms_meas[nlma] = intersceneLma.getLastRms()[1]; // re-measured pure RMS at this cycle's pose // By Claude on 07/12/2026
			// pose_lma_debug >= 2: per-inner-step solver lines; header attributes them to
			// their outer cycle (the solver lines carry no scene/cycle context).
			// By Claude on 07/12/2026, from Andrey's request.
			final int lma_debug = Math.max(debugLevel, clt_parameters.curt.pose_lma_debug);
			if (clt_parameters.curt.pose_lma_debug >= 2) {
				System.out.println("CuasPoseRT innerLMA: scene "+scene.getImageName()+
						" outer cycle "+nlma+" (lambda0 "+lma_lambda+"):");
			}
			final int lmaResult = intersceneLma.runLma(
					lma_lambda, // curt.pose_lambda override (0 = legacy ilp.ilma_lambda) // By Claude on 07/12/2026
					clt_parameters.ilp.ilma_lambda_scale_good,
					clt_parameters.ilp.ilma_lambda_scale_bad,
					clt_parameters.ilp.ilma_lambda_max,
					clt_parameters.ilp.ilma_rms_diff,
					// lean-only inner cap (Andrey 07/13: 1 = validated static shape; the shared
					// imp.max_LMA stays high for varying-scene applications). By Claude on 07/13/2026.
					(clt_parameters.curt.pose_max_lma > 0) ?
							clt_parameters.curt.pose_max_lma : clt_parameters.imp.max_LMA,
					false,                            // last_run
					lma_debug); // curt.pose_lma_debug boost (inner solver only) // By Claude on 07/12/2026
			if (lmaResult < 0) {
				System.out.println("leanFitScene(): LMA failed, lmaResult="+lmaResult);
				PoseCorrExport.sceneFailed(scene); // drop a partial pose_corr capture (armed only) // By Claude on 07/13/2026
				return null;
			}
			scene_xyzatr0 = intersceneLma.getSceneXYZATR(false);
			final double [] diffs_atr = intersceneLma.getV3Diff(ErsCorrection.DP_DSAZ);
			final double [] diffs_xyz = intersceneLma.getV3Diff(ErsCorrection.DP_DSX);
			last_diff_atr = diffs_atr[0]; // By Claude on 07/12/2026
			cycle_datr[nlma] = diffs_atr[0]; // By Claude on 07/12/2026
			cycle_inner[nlma] = lmaResult;   // inner runLma iterations this cycle // By Claude on 07/12/2026
			cycle_rms_solved[nlma] = intersceneLma.getLastRms()[1]; // model-predicted pure RMS after the solve // By Claude on 07/12/2026
			ncyc = nlma + 1;                 // By Claude on 07/12/2026
			if ((fixed_cycles <= 0) && // lean v2: fixed count = no early exit // By Claude on 07/12/2026
					(diffs_atr[0] < clt_parameters.imp.exit_change_atr) &&
					(diffs_xyz[0] < clt_parameters.imp.exit_change_xyz)) {
				break;
			}
		}
		// QC diagnostic (fixed-cycle mode): flag scenes whose last ATR change would have
		// missed the legacy convergence threshold - data for choosing between 2 and 3
		// cycles, and a canary for scene classes that ever need more. Never steers the
		// loop. By Claude on 07/12/2026, from Andrey's design.
		if ((fixed_cycles > 0) && !(last_diff_atr < clt_parameters.imp.exit_change_atr) && (debugLevel > -4)) {
			System.out.println("CuasPoseRT QC: scene "+scene.getImageName()+" pose NOT settled after "+
					fixed_cycles+" fixed cycles: last ATR change "+last_diff_atr+
					" >= exit_change_atr "+clt_parameters.imp.exit_change_atr);
		}
		if (ncyc_out != null) ncyc_out[0] = ncyc; // for the one-line scene report // By Claude on 07/13/2026
		// Per-cycle convergence trajectory (the "why does it take that many cycles"
		// investigation, Andrey 07/12/2026): geometric decay = healthy damped iteration,
		// a stall/plateau = something re-anchoring each cycle (pull term, lambda warm-up,
		// prediction). One compact line per scene; gated by pose_lma_debug >= 1 after the
		// 07/13 verbosity reduction (the investigation is closed - cyc= now rides the
		// scene Done line). By Claude on 07/12/2026, gate 07/13.
		if ((clt_parameters.curt.pose_lma_debug >= 1) && (debugLevel > -4)) {
			final StringBuilder tsb = new StringBuilder();
			for (int i = 0; i < ncyc; i++) tsb.append(String.format(" %.3g", cycle_datr[i]));
			final StringBuilder isb = new StringBuilder(); // inner runLma iterations per cycle // By Claude on 07/12/2026
			for (int i = 0; i < ncyc; i++) isb.append(" "+cycle_inner[i]);
			// meas>solved pairs: re-measured pure RMS at cycle start > model-predicted after
			// the solve. Chain-rule error = the NEXT meas bounces back above this solved.
			// By Claude on 07/12/2026, from Andrey's hypothesis.
			final StringBuilder rsb = new StringBuilder();
			for (int i = 0; i < ncyc; i++) rsb.append(String.format(" %.4f>%.4f",
					cycle_rms_meas[i], cycle_rms_solved[i]));
			System.out.println("CuasPoseRT cycles: scene "+scene.getImageName()+
					" ("+ncyc+" of max "+max_cycles+", lambda0 "+lma_lambda+") dATR/cycle ="+tsb+
					"; innerLMA ="+isb+"; RMS meas>solved ="+rsb);
		}
		if (lma_rms != null) {
			final double [] last_rms = intersceneLma.getLastRms();
			lma_rms[0] = last_rms[0];
			if (lma_rms.length > 1) lma_rms[1] = last_rms[1];
			double sw = 0; int nm = 0;
			for (double [] v : cm[1]) if (v != null) { sw += v[2]; nm++; }
			if (lma_rms.length > 2) lma_rms[2] = sw;
			if (lma_rms.length > 3) lma_rms[3] = nm;
		}
		if (coord_motion_rslt != null) {
			for (int i = 0; (i < coord_motion_rslt.length) && (i < cm.length); i++) {
				coord_motion_rslt[i] = cm[i];
			}
		}
		if (debugLevel > -3) {
			System.out.println("Adjusted interscene, iteration="+nlma+", last RMS = "+
					intersceneLma.getLastRms()[0]+" (lean)");
		}
		// pose_corr capture complete: all measure<->solve iterations of this scene are
		// recorded - write the test case (armed only). By Claude on 07/13/2026.
		PoseCorrExport.sceneDone(scene);
		return scene_xyzatr0;
	}

	/**
	 * Re-generate per-scene poses against the virtual-center reference, RT-style.
	 *
	 * TILE-CALIBRATION BOOTSTRAP (Andrey's missing-file rule, 07/05/2026): calibrations
	 * are FILE-gated, per scene sequence (the file lives in this sequence's CENTER model
	 * directory). If -POSE-RT-TILE-CALIB is missing (fresh v013_min copy - or DELETED
	 * to force: the ONLY force method, no configuration checkbox), pass 1 runs FULL
	 * (all strength-selected tiles), generates
	 * and saves the calibration, and the sequence AUTOMATICALLY re-runs filtered
	 * (~150 cluster tiles): the first run is slow but ends in the same state as a warmed
	 * system, and all outputs are the filtered-run products (pass-1 outputs are
	 * overwritten; only the calibration file survives from pass 1). Any other sequence
	 * with its calibration already present bypasses the bootstrap entirely.
	 * Force a rebuild by ERASING the file - no configuration changes needed.
	 *
	 * @param clt_parameters processing parameters (uses curt.pose_str threshold,
	 *                       imp.lma_use_R, LMA/offset limits as the offline flow)
	 * @param center_CLT     virtual-center reference: must have CenterClt (fclt) and
	 *                       the final combo DSI restored
	 * @param quadCLTs       scene sequence (time-ascending indices), instances built
	 *                       with image data (e.g. by CuasRanging.prepareFpixels())
	 * @param debugLevel     debug level
	 * @return fitted poses [nscene][2][3] ({xyz,atr}, xyz locked to zero) or null
	 */
	public static double [][][] testPoseSequence(
			final CLTParameters clt_parameters,
			final QuadCLT       center_CLT,
			final QuadCLT []    quadCLTs,
			final int           debugLevel) {
		final boolean [] saved_calib = new boolean [1];
		double [][][] fitted = poseSequencePass(clt_parameters, center_CLT, quadCLTs,
				saved_calib, // out: set when this pass generated+saved the tile calibration
				debugLevel);
		if (saved_calib[0]) { // bootstrap: calibration was just generated - re-run filtered
			System.out.println("CuasPoseRT.testPoseSequence(): BOOTSTRAP - "+TILE_CALIB_SUFFIX+
					" was just generated; re-running the sequence with the filtered selection"+
					" (all outputs are overwritten by the filtered run)");
			fitted = poseSequencePass(clt_parameters, center_CLT, quadCLTs,
					null,    // saved_calib: a filtered pass never writes the calibration
					debugLevel);
		}
		return fitted;
	}

	/** The single-pass body of {@link #testPoseSequence}: one FULL or filtered sequence.
	 *  By Claude on 07/05/2026 (bootstrap split). */
	private static double [][][] poseSequencePass(
			final CLTParameters clt_parameters,
			final QuadCLT       center_CLT,
			final QuadCLT []    quadCLTs,
			final boolean []    saved_calib,  // out (may be null): [0]=true if the tile calibration was saved
			final int           debugLevel) {
		final double  min_str =     clt_parameters.curt.pose_str;   // e.g. 1.0
		final int     margin =      clt_parameters.imp.margin;
		final double  mb_tau =      clt_parameters.imp.mb_tau;
		final double  mb_max_gain = clt_parameters.imp.mb_max_gain_inter;
		final double  min_offset =  clt_parameters.imp.min_offset;
		final double  max_offset =  clt_parameters.imp.max_rel_offset *
				center_CLT.getTilesX() * center_CLT.getTileSize();
		final double  max_rms =     clt_parameters.imp.eig_use ?
				clt_parameters.imp.eig_max_rms : clt_parameters.imp.max_rms;
		final double [] reg_weights = clt_parameters.ilp.ilma_regularization_weights;
		final double [] lma_rms = new double [clt_parameters.imp.eig_use ? 6 : 4];

		// 3-angle parameter selection: azimuth, tilt (+ optionally roll), XYZ locked
		// (rotation-only pose: displacements are good enough from the mechanical
		// construction; same as offline CUAS lock_position=true)
		final boolean [] param_select = ErsCorrection.getParamSelect(
				false,                          // use_Z (lock_position)
				clt_parameters.imp.lma_use_R,   // use_R
				false,                          // use_XY (lock_position)
				true,                           // use_AT
				false, false, false);           // ERS az/tilt/roll - off
		if (debugLevel > -3) {
			System.out.print("CuasPoseRT.testPoseSequence(): param_select=[");
			for (boolean b : param_select) System.out.print(b ? "+" : "-");
			System.out.println("], min_str="+min_str+", max_offset="+max_offset);
		}

		// ---- Reference prep (ONCE) - the persistent virtual-center TD reference ----
		final boolean is_aux = center_CLT.isAux();
		final double [][] center_dsi = center_CLT.dsi;
		final double [] strength =      center_dsi[is_aux ? TwoQuadCLT.DSI_STRENGTH_AUX      : TwoQuadCLT.DSI_STRENGTH_MAIN];
		// ONE disparity source for all of RT (Andrey ruling, certified in the render test):
		// the PLAIN slice conditioned to be defined for ALL tiles (sky = infinity/0,
		// negatives clamped). NOT gated by use_lma_dsi (the LMA slice is NaN over the sky).
		// By Claude on 07/05/2026.
		final double [] center_disparity = CuasRender.conditionDisparityCuas(
				center_dsi[is_aux ? TwoQuadCLT.DSI_DISPARITY_AUX : TwoQuadCLT.DSI_DISPARITY_MAIN],
				center_CLT.getTilesX(),               // By Claude on 07/07/2026 (small-FG Laplacian fill)
				clt_parameters.curt.disp_fg_tiles,
				clt_parameters.curt.disp_fg_over);
		final boolean [] reliable_ref = new boolean [strength.length];
		int num_reliable = 0;
		for (int i = 0; i < strength.length; i++) {
			reliable_ref[i] = (strength[i] > min_str) && !Double.isNaN(center_disparity[i]);
			if (reliable_ref[i]) num_reliable++;
		}
		if (debugLevel > -3) {
			System.out.println("CuasPoseRT.testPoseSequence(): num_reliable="+num_reliable+
					" (strength > "+min_str+")");
		}
		// Automatic FPN-style calibration reuse: if -POSE-RT-TILE-CALIB exists (and not forced to
		// recalc), derive the selection from it (per-tile max residual; NMAD outlier gate +
		// rank-N budget) and AND with the strength mask - a filtered run. Otherwise run FULL
		// (all strength-selected tiles) and generate the calibration at the end.
		boolean full_selection = true; // the tile calibration is (re)saved only from a full-selection run
		// pose_full (DEBUG): all strength-selected tiles, calibration neither read nor written
		// (a measurement-debug run must not pollute the persistent tile calibration). By Claude on 07/04/2026.
		// File-gated (no force checkbox - DELETE the file to force a rebuild, Andrey 07/06/2026)
		ImagePlus imp_max = clt_parameters.curt.pose_full ? null :
				center_CLT.readImagePlusFromModelDirectory(TILE_CALIB_SUFFIX);
		if ((imp_max == null) && !clt_parameters.curt.pose_full) { // legacy name fallback
			imp_max = center_CLT.readImagePlusFromModelDirectory(TILE_CALIB_SUFFIX_OLD);
		}
		if (clt_parameters.curt.pose_full) {
			System.out.println("CuasPoseRT.testPoseSequence(): pose_full ON (DEBUG) - using ALL "+num_reliable+
					" strength-selected tiles, tile calibration NOT read/written");
		}
		if (imp_max != null) {
			final float [] fmax = (float []) imp_max.getProcessor().getPixels();
			final boolean [] filt = deriveSelection(
					fmax,
					center_CLT.getTilesX(),
					clt_parameters.curt.pose_dxy_k,
					clt_parameters.curt.pose_num_tiles,
					clt_parameters.curt.pose_shrink, // interior gate (3-A3) // By Claude on 07/13/2026
					debugLevel);
			int num_filt = 0;
			for (int i = 0; i < reliable_ref.length; i++) {
				reliable_ref[i] &= (i < filt.length) && filt[i];
				if (reliable_ref[i]) num_filt++;
			}
			full_selection = false;
			System.out.println("CuasPoseRT.testPoseSequence(): reusing "+TILE_CALIB_SUFFIX+" calibration, filtered to "+
					num_filt+" tiles (of "+num_reliable+" by strength); delete "+
					center_CLT.getImageName()+TILE_CALIB_SUFFIX+".tiff (model dir) to recalculate");
		} else {
			System.out.println("CuasPoseRT.testPoseSequence(): "+TILE_CALIB_SUFFIX+" not found"+
					" - FULL run, will generate the calibration"+
					(clt_parameters.curt.pose_full ? "" : ", then re-run filtered (bootstrap)"));
		}
		final double [][] pXpYD_center = OpticalFlow.transformToScenePxPyD(
				null,             // final Rectangle [] extra_woi,
				center_disparity, // final double []    disparity_ref,
				ZERO3,            // final double []    scene_xyz,
				ZERO3,            // final double []    scene_atr,
				center_CLT,       // final QuadCLT      scene_QuadClt,
				center_CLT);      // final QuadCLT      reference_QuadClt
		final float [][] fclt = new float [][] {center_CLT.getCenterClt()};
		final TpTask [][] tp_tasks_center = Interscene.setReferenceGPU(
				clt_parameters,   // CLTParameters      clt_parameters,
				center_CLT,       // QuadCLT            ref_scene,
				center_disparity, // double []          ref_disparity,
				pXpYD_center,     // double [][]        ref_pXpYD,
				fclt,             // final float [][]   fclt,
				reliable_ref,     // final boolean []   selection,
				margin,           // final int          margin,
				mb_tau,           // double             mb_tau,
				mb_max_gain,      // double             mb_max_gain,
				null,             // double [][]        mb_vectors (center does not move)
				debugLevel);      // int                debug_level
		// The borrowed center arrives ALREADY APPROPRIATED (zero-DC, in memory) from
		// CuasRT.appropriateCenter() - the borrow boundary at RT initialization; the
		// getCenterClt()/setReferenceGPU above therefore uploaded the zeroed reference.
		// Nothing to compensate here. By Claude on 07/05/2026, per Andrey's ruling.

		// ---- Stored poses (comparison truth + seed) ----
		final ErsCorrection ers_center = center_CLT.getErsCorrection();
		final double [][][] stored = new double [quadCLTs.length][][];
		for (int i = 0; i < quadCLTs.length; i++) if (quadCLTs[i] != null) {
			stored[i] = ers_center.getSceneXYZATR(quadCLTs[i].getImageName()); // may be null
		}
		int earliest = -1;
		for (int i = 0; i < quadCLTs.length; i++) if (quadCLTs[i] != null) { earliest = i; break; }
		if (earliest < 0) {
			System.out.println("CuasPoseRT.testPoseSequence(): no scenes");
			return null;
		}
		// Seed: earliest stored pose ("cheating" bootstrap); identity if unavailable.
		// Any scene works as the entry point - every fit is anchored to the virtual
		// center, not chained to neighbors.
		double [][] predicted = (stored[earliest] != null) ?
				new double [][] {stored[earliest][0].clone(), stored[earliest][1].clone()} :
				new double [][] {ZERO3.clone(), ZERO3.clone()};
		if (stored[earliest] == null) {
			System.out.println("CuasPoseRT.testPoseSequence(): WARNING - no stored pose for seed scene "+
					quadCLTs[earliest].getImageName()+", seeding with identity");
		}

		// Angle->pixel conversion, same scales the LMA uses (GeometryCorrection par_scales):
		// az/tilt ~ focal length, roll ~ distortion radius (edge-of-field displacement)
		final double px_per_rad_at = 1000.0 * center_CLT.getGeometryCorrection().focalLength /
				center_CLT.getGeometryCorrection().pixelSize;
		final double px_per_rad_r =  1000.0 * center_CLT.getGeometryCorrection().distortionRadius /
				center_CLT.getGeometryCorrection().pixelSize;
		final double [] px_per_rad = {px_per_rad_at, px_per_rad_at, px_per_rad_r};

		// PER-SEQUENCE DC level for the A2 scene conditioning - NOT per-scene: a per-scene
		// sky median oscillates with rotation (terrain sweeps the sky half; 234-count pk-pk
		// at exactly 1 rev period, POSE-10 forensics) and downstream temporal averages turn
		// it into content ghosts. ONE constant (CuasRT.appropriateCenter derived it from
		// the borrowed center = the average scene). By Claude on 07/05/2026, Andrey's rule.
		final CuasConditioning.Config cond_cfg = CuasConditioning.configFromCurt(clt_parameters.curt); // rowcol_* now dialog-driven (RT step 2a) // By Claude on 07/07/2026
		if (clt_parameters.curt.pose_raw) {
			cond_cfg.dc_level = CuasRT.sequenceDcLevel(clt_parameters, center_CLT);
			if (Double.isNaN(cond_cfg.dc_level)) {
				System.out.println("CuasPoseRT: WARNING - no per-sequence DC level (RT-STATE missing, curt.dc_center==0):"+
						" falling back to the PER-SCENE sky median (rotation-synced DC flicker!)");
			}
		}
		// ---- Ascending per-scene loop (the RT iterator) ----
		final int tilesX = center_CLT.getTilesX();
		final int tilesY = center_CLT.getTilesY();
		final int num_pix = tilesX * tilesY;
		final ArrayList<float [][]> hyper_scenes = new ArrayList<float [][]>(); // per used scene: [component][tile]
		final ArrayList<String>     hyper_ts =     new ArrayList<String>();
		if (!clt_parameters.imp.eig_use && (debugLevel > -4)) {
			System.out.println("CuasPoseRT.testPoseSequence(): imp.eig_use is OFF - the 4 eigen slices of "+
					"-POSE-RT-HYPER (sqrt_l0, sqrt_l1, elong, eig0_ang) will be all NaN");
		}
		// Phase B lean engine (curt_pose_lean): ImageDtt instance for the convert stage,
		// created once (mirrors interCorrPair's construction)
		final ImageDtt image_dtt_lean = clt_parameters.curt.pose_lean ? new ImageDtt(
				center_CLT.getNumSensors(),
				clt_parameters.transform_size,
				clt_parameters.img_dtt,
				center_CLT.isAux(),
				center_CLT.isMonochrome(),
				center_CLT.isLwir(),
				clt_parameters.getScaleStrength(center_CLT.isAux()),
				center_CLT.getGPU()) : null;
		if (clt_parameters.curt.pose_lean) {
			System.out.println("CuasPoseRT.testPoseSequence(): LEAN measurement engine (phase B v2, "+
					"certified CuasRender chain): TD-average(16) x virtual-center single conj-multiply, "+
					"all-defined conditioned disparity, MB compensation "+
					(clt_parameters.imp.mb_en ? "ON (compare vs MBEN/stored)" : "OFF (compare vs NOMB)"));
		}
		if (clt_parameters.curt.pose_stored) { // By Claude on 07/04/2026
			System.out.println("CuasPoseRT.testPoseSequence(): pose_stored ON (DEBUG) - STORED poses, "+
					"NO LMA: measurement/render chain only"+
					(clt_parameters.curt.pose_lean ? "" : " (only implemented for the LEAN engine - ignored)"));
		}
		// DEBUG (pose_corr_save): render per-scene pixel-domain correlations vs the virtual
		// center (-POSE-RT-CORR2D, z=scenes, last LMA cycle, 16x16 cells per tile) - the same
		// visual debugging used on the oracle. Lean only: the oracle engine does not expose
		// its correlation tiles. By Claude on 07/04/2026, per Andrey's debug approach.
		final boolean corr_save = clt_parameters.curt.pose_corr_save && clt_parameters.curt.pose_lean;
		if (clt_parameters.curt.pose_corr_save && !clt_parameters.curt.pose_lean) {
			System.out.println("CuasPoseRT.testPoseSequence(): pose_corr_save is only implemented "+
					"for the LEAN engine (pose_lean) - ignored");
		}
		final int corr_size = 2 * com.elphel.imagej.gpu.GPUTileProcessor.DTT_SIZE - 1; // 15
		final ImageStack stack_corr = corr_save ? new ImageStack(tilesX*(corr_size+1), tilesY*(corr_size+1)) : null;
		final double [][][] corr_pd_holder = corr_save ? new double [1][][] : null;
		final float [] corr_nan_slice = corr_save ? new float [tilesX*(corr_size+1)*tilesY*(corr_size+1)] : null;
		if (corr_nan_slice != null) Arrays.fill(corr_nan_slice, Float.NaN);
		// DEBUG (pose_img_save): the composite (TD-averaged, grid-transformed) scenes actually
		// correlated against the virtual center + the center reference render itself.
		// By Claude on 07/04/2026, per Andrey's debug approach.
		final boolean img_save = clt_parameters.curt.pose_img_save && clt_parameters.curt.pose_lean;
		if (clt_parameters.curt.pose_img_save && !clt_parameters.curt.pose_lean) {
			System.out.println("CuasPoseRT.testPoseSequence(): pose_img_save is only implemented "+
					"for the LEAN engine (pose_lean) - ignored");
		}
		final int img_width =  center_CLT.getGPUQuad().getImageWidth();
		final int img_height = center_CLT.getGPUQuad().getImageHeight();
		final ArrayList<float []> img_scenes = img_save ? new ArrayList<float []>() : null;
		final float [][] img_holder = img_save ? new float [1][] : null;
		final float [] img_nan_slice = img_save ? new float [img_width*img_height] : null;
		if (img_nan_slice != null) Arrays.fill(img_nan_slice, Float.NaN);
		// the virtual-center reference render (what every scene correlates against) - becomes
		// the second component of the -POSE-RT-COMPOSITE hyperstack, repeated for every scene
		final float [] ref_render = img_save ? com.elphel.imagej.cuas.CuasMotion.perSensorImagesFromTD(
				center_CLT.getGPUQuad(), true)[0] : null; // reference buffer, all slots = center
		final double [][][] fitted = new double [quadCLTs.length][][];
		final int [] fail_reason = new int [1];
		final StringBuffer sb = new StringBuffer();
		sb.append("index, timestamp, pred_az, pred_tilt, pred_roll, fit_az, fit_tilt, fit_roll,"+
				" stored_az, stored_tilt, stored_roll, dfit_az, dfit_tilt, dfit_roll, rms, weight, num_tiles, fail\n");
		int num_ok = 0, num_fail = 0;
		double [] sum2_d = new double [3];
		double [] max_d =  new double [3];
		int num_cmp = 0;
		double prev_ts = Double.NaN;
		double [][] prev_pose = null;
		// FAKE PREVIOUS pose for the seed scene (Andrey 07/13/2026): step BACK from the
		// first two known stored poses so scene 0 gets real finite-difference rates
		// (uniform MB + ERS) like every other scene, instead of the zero-rate special
		// case (mb=(0,0) left the seed fit carrying the uncompensated v*tau bias).
		// Backward difference of two ACTUAL poses - no hand-derived rotation rate, no
		// sign-convention risk. In RT production a continuous stream always has a real
		// previous frame; this covers batch runs and stream (re)starts, where the
		// nominal-rotation config can feed the same prev_pose/prev_ts seam.
		// Note: changes ONLY the seed scene's rates -> the next full-sequence run is
		// deliberately NOT digit-identical to the 07/13 baseline in scene 0.
		// By Claude on 07/13/2026, from Andrey's design.
		if (stored[earliest] != null) {
			final double ts0 = Double.parseDouble(quadCLTs[earliest].getImageName().replace("_", "."));
			for (int ns2 = earliest + 1; ns2 < quadCLTs.length; ns2++) {
				if ((quadCLTs[ns2] == null) || (stored[ns2] == null)) continue;
				final double ts2 = Double.parseDouble(quadCLTs[ns2].getImageName().replace("_", "."));
				if (!(ts2 > ts0)) break; // non-monotonic names - keep the legacy zero-rate seed
				prev_ts = ts0 - (ts2 - ts0);
				prev_pose = new double [][] {predicted[0].clone(), new double [3]};
				for (int j = 0; j < 3; j++) { // rate at the seed = exactly (stored[ns2]-stored[earliest])/(ts2-ts0)
					prev_pose[1][j] = predicted[1][j] - (stored[ns2][1][j] - stored[earliest][1][j]);
				}
				if (debugLevel > -4) {
					System.out.println(String.format(
							"CuasPoseRT.testPoseSequence(): FAKE previous for seed scene %s from stored poses"+
							" %s/%s: atr_dt=(%.4g, %.4g, %.4g) rad/s",
							quadCLTs[earliest].getImageName(), quadCLTs[earliest].getImageName(),
							quadCLTs[ns2].getImageName(),
							(stored[ns2][1][0] - stored[earliest][1][0]) / (ts2 - ts0),
							(stored[ns2][1][1] - stored[earliest][1][1]) / (ts2 - ts0),
							(stored[ns2][1][2] - stored[earliest][1][2]) / (ts2 - ts0)));
				}
				break;
			}
		}
		for (int nscene = earliest; nscene < quadCLTs.length; nscene++) {
			if (quadCLTs[nscene] == null) continue;
			final String ts_name = quadCLTs[nscene].getImageName();
			final double ts = Double.parseDouble(ts_name.replace("_", "."));
			// ERS velocities from finite differences of the pose sequence (substitute
			// for the offline PIMU rates; only the residual/wobble rates ~0.05 rad/s -
			// ERS params are not fitted, disable_ers=true below)
			double [][] dxyzatr_dt = new double [][] {ZERO3.clone(), ZERO3.clone()};
			if ((prev_pose != null) && !Double.isNaN(prev_ts) && (ts > prev_ts)) {
				for (int j = 0; j < 3; j++) {
					dxyzatr_dt[1][j] = (predicted[1][j] - prev_pose[1][j]) / (ts - prev_ts);
				}
			}
			// pose_stored (DEBUG): use the STORED per-scene ERS rates (xyz_dt AND atr_dt), the
			// same source and calls as the render path (renderSceneSequence) - so the decoupled
			// measurement/render applies IDENTICAL ERS to the CUAS-MERGED-CUAS-DBG renders.
			// Live mode keeps finite-difference ATR rates (xyz_dt=0) - the ErsCorrection-vs-
			// render divergence under test. By Claude on 07/04/2026, from Andrey's diagnosis.
			double [] stored_ers_xyz_dt = null, stored_ers_atr_dt = null;
			if (clt_parameters.curt.pose_stored) {
				stored_ers_xyz_dt = ers_center.getSceneErsXYZ_dt(ts_name);
				stored_ers_atr_dt = ers_center.getSceneErsATR_dt(ts_name);
			}
			if ((stored_ers_xyz_dt != null) && (stored_ers_atr_dt != null)) {
				quadCLTs[nscene].getErsCorrection().setErsDt(
						stored_ers_xyz_dt,   // double []    ers_xyz_dt,
						stored_ers_atr_dt);  // double []    ers_atr_dt - exactly as renderSceneSequence
			} else {
				if (clt_parameters.curt.pose_stored && (debugLevel > -4)) {
					System.out.println("CuasPoseRT: scene "+nscene+" ("+ts_name+
							") pose_stored: no stored ERS rates, falling back to finite differences");
				}
				quadCLTs[nscene].getErsCorrection().setErsDt(
						QuadCLTCPU.scaleDtToErs(clt_parameters, dxyzatr_dt));
			}
			// Phase A2 ingest (curt_pose_raw): read RAW /jp4/, condition with the CURRENT
			// calibration and force-upload straight to the GPU - bypasses the prepared
			// image_data (old broken Photogrammetric Calibration). The bayer guard keeps
			// the upload alive through interCorrPair's own setBayerImages(false).
			// A2 results legitimately diverge from phase A (cleaner input) - judge by own
			// dstored quality. By Claude on 07/05/2026, from Andrey's design.
			if (clt_parameters.curt.pose_raw) {
				final boolean raw_ok = CuasConditioning.conditionSceneToGpu(
						quadCLTs[nscene],           // QuadCLT scene (jp4 + calibration + FPN)
						cond_cfg,                   // Config: defaults + the PER-SEQUENCE dc_level // By Claude on 07/05/2026
						ImageDtt.THREADS_MAX,       // read threads
						debugLevel - 2);            // debug
				if (!raw_ok) {
					System.out.println("CuasPoseRT: scene "+nscene+" ("+ts_name+
							") raw-jp4 ingest FAILED - coasting prediction");
					fitted[nscene] = new double [][] {predicted[0].clone(), predicted[1].clone()};
					num_fail++;
					// keep CSV/hyper alignment: record an empty measurement row
					final float [][] nan_comps = new float [HYPER_COMPONENTS.length][num_pix];
					for (float [] c : nan_comps) Arrays.fill(c, Float.NaN);
					hyper_scenes.add(nan_comps);
					hyper_ts.add(ts_name);
					if (stack_corr != null) stack_corr.addSlice(ts_name, corr_nan_slice.clone()); // keep z aligned // By Claude on 07/04/2026
					if (img_scenes != null) img_scenes.add(img_nan_slice.clone()); // By Claude on 07/04/2026
					sb.append(nscene+", "+ts_name);
					for (int j = 0; j < 12; j++) sb.append(", NaN");
					sb.append(", NaN, 0.0, 0, -1\n");
					prev_pose = predicted;
					prev_ts = ts;
					continue;
				}
			}
			// Motion-blur compensation (gated by the same imp.mb_en as offline): per-tile blur
			// vectors from the finite-difference rates -> interCorrPair takes the
			// setInterTasksMotionBlur/interCorrTDMotionBlur path (convert_direct runs twice,
			// the second run subtracts the shifted+scaled copy via negative TpTask.scale -
			// removes the LWIR bolometer exponential tail). mb_en OFF -> single-run path (A/B).
			double [][] mb_vectors_scene = null;
			if (clt_parameters.imp.mb_en) {
				if (clt_parameters.curt.pose_mb_uniform) {
					// lean v2: single shared vector for all tiles (CUAS constant-rpm flow;
					// roll component and wobble ignored by ruling). By Claude on 07/12/2026.
					mb_vectors_scene = uniformMotionBlur(
							center_CLT,          // QuadCLT        center_CLT,
							quadCLTs[nscene],    // QuadCLT        scene,
							pXpYD_center,        // double [][]    pXpYD_center,
							predicted[0],        // double []      camera_xyz,
							predicted[1],        // double []      camera_atr,
							dxyzatr_dt[0],       // double []      camera_xyz_dt,
							dxyzatr_dt[1],       // double []      camera_atr_dt,
							debugLevel - 2);     // int            debugLevel
					if (mb_vectors_scene == null) {
						System.out.println("CuasPoseRT: scene "+ts_name+
								" uniform MB degenerate - falling back to per-tile getMotionBlur");
					} else if (clt_parameters.curt.pose_lma_debug >= 1) {
						// verbosity reduction 07/13: mb=() now rides the scene Done line
						System.out.println(String.format(
								"CuasPoseRT: scene %s uniform MB vector = (%.4f, %.4f) px/s",
								ts_name, mb_vectors_scene[0][0], mb_vectors_scene[1][0]));
					}
				}
				if (mb_vectors_scene == null) { // legacy per-tile path (pose_mb_uniform OFF or degenerate fallback) // By Claude on 07/12/2026
					mb_vectors_scene = OpticalFlow.getMotionBlur(
							center_CLT,          // QuadCLT        ref_scene,
							quadCLTs[nscene],    // QuadCLT        scene,
							pXpYD_center,        // double [][]    ref_pXpYD,
							predicted[0],        // double []      camera_xyz,
							predicted[1],        // double []      camera_atr,
							dxyzatr_dt[0],       // double []      camera_xyz_dt,
							dxyzatr_dt[1],       // double []      camera_atr_dt,
							0,                   // int            shrink_gaps,
							debugLevel - 2);     // int            debug_level
				}
			}
			fail_reason[0] = 0;
			final int [] ncyc_scene = {0}; // outer cycles executed (lean path; 0 = other paths) // By Claude on 07/13/2026
			final double [][][] coord_motion_rslt = new double [3][][]; // [centers, vector_XYS, eigen] of the last LMA cycle
			if (corr_pd_holder != null) corr_pd_holder[0] = null; // no stale slices on failure // By Claude on 07/04/2026
			if (img_holder != null) img_holder[0] = null; // By Claude on 07/04/2026
			double [][] pose;
			if (clt_parameters.curt.pose_lean && clt_parameters.curt.pose_stored) {
				// DEBUG (pose_stored): measurement/render at the STORED pose, NO LMA - decouples
				// the task/render/correlation chain from the solver. The composite must then match
				// the oracle CUAS-MERGED-CUAS-DBG renders tile-for-tile. By Claude on 07/04/2026.
				final double [][] use_pose = (stored[nscene] != null) ? stored[nscene] : predicted;
				if (stored[nscene] == null) {
					System.out.println("CuasPoseRT: scene "+nscene+" ("+ts_name+") pose_stored: no stored pose, using prediction");
				}
				final double [][][] cm = leanMeasure(
						clt_parameters, image_dtt_lean, center_CLT, quadCLTs[nscene],
						center_disparity, pXpYD_center, reliable_ref,
						use_pose[0], use_pose[1], margin,
						mb_vectors_scene, mb_tau, mb_max_gain, // MB per imp.mb_en // By Claude on 07/05/2026
						corr_pd_holder, img_holder, debugLevel - 2);
				if (cm != null) {
					pose = new double [][] {use_pose[0].clone(), use_pose[1].clone()};
					for (int i = 0; (i < coord_motion_rslt.length) && (i < cm.length); i++) {
						coord_motion_rslt[i] = cm[i];
					}
					double sw = 0, s2 = 0; int nm = 0;
					for (double [] v : cm[1]) if (v != null) {
						sw += v[2];
						s2 += v[2]*(v[0]*v[0] + v[1]*v[1]);
						nm++;
					}
					lma_rms[0] = (sw > 0) ? Math.sqrt(s2/sw) : Double.NaN; // weighted RMS of measured offsets
					if (lma_rms.length > 2) lma_rms[2] = sw;
					if (lma_rms.length > 3) lma_rms[3] = nm;
				} else {
					pose = null;
					fail_reason[0] = 1;
				}
			} else if (clt_parameters.curt.pose_lean) {
				pose = leanFitScene(
						clt_parameters,      // CLTParameters  clt_parameters,
						image_dtt_lean,      // ImageDtt       image_dtt,
						center_CLT,          // QuadCLT        center_CLT,
						quadCLTs[nscene],    // QuadCLT        scene,
						center_disparity,    // double []      center_disparity,
						pXpYD_center,        // double [][]    pXpYD_center,
						reliable_ref,        // boolean []     selection,
						predicted,           // double [][]    predicted (initial + pull),
						param_select,        // boolean []     param_select,
						reg_weights,         // double []      reg_weights,
						margin,              // int            margin,
						mb_vectors_scene,    // double [][]    mb_vectors (MB per imp.mb_en - lean v2) // By Claude on 07/05/2026
						mb_tau,              // double         mb_tau, // By Claude on 07/05/2026
						mb_max_gain,         // double         mb_max_gain, // By Claude on 07/05/2026
						lma_rms,             // double []      lma_rms (out),
						coord_motion_rslt,   // double [][][]  coord_motion_rslt (out),
						corr_pd_holder,      // double [][][]  corr_pd_out (last-cycle PD correlations) // By Claude on 07/04/2026
						img_holder,          // float [][]     img_out (last-cycle composite render) // By Claude on 07/04/2026
						ncyc_scene,          // int []         ncyc_out (outer cycles, for the one-line report) // By Claude on 07/13/2026
						debugLevel - 2);     // int            debug_level
				if (pose == null) fail_reason[0] = 1; // measurement/LMA
			} else {
			pose = Interscene.adjustPairsLMAInterscene(
					clt_parameters,      // CLTParameters  clt_parameters,
					true,                // boolean        initial_adjust,
					false,               // boolean        fpn_disable,
					true,                // boolean        disable_ers,
					new double [] {min_offset, max_offset, 0.0}, // double [] min_max,
					fail_reason,         // int []         fail_reason,
					center_CLT,          // QuadCLT        ref_QuadClt (virtual center)
					center_disparity,    // double []      ref_disparity,
					center_CLT,          // QuadCLT        first_QuadClt (virtual center)
					pXpYD_center,        // double [][]    pXpYD_ref,
					clt_parameters.imp.cuas_min_confidence, // double min_confidence (as re-adjust: final-DSI vintage)
					reliable_ref,        // boolean []     reliable_ref,
					null,                // boolean []     reliable_all,
					tp_tasks_center[0],  // TpTask[]       tp_tasks_ref,
					quadCLTs[nscene],    // QuadCLT        scene_QuadClt,
					predicted,           // double [][]    camera_xyzatr (initial),
					predicted,           // double [][]    scene_xyzatr_pull,
					new double [2][3],   // double [][]    ref_xyzatr,
					param_select,        // boolean[]      param_select,
					reg_weights,         // double []      param_regweights,
					lma_rms,             // double []      rms_out,
					max_rms,             // double         max_rms,
					mb_tau,              // double         mb_tau,
					mb_max_gain,         // double         mb_max_gain,
					mb_vectors_scene,    // double [][]    mb_vectors (null when imp.mb_en OFF - single-run path),
					coord_motion_rslt,   // double [][][]  coord_motion_rslt (last-cycle measurement, for -POSE-RT-HYPER),
					debugLevel - 2);     // int            debug_level
			}
			final boolean ok = (pose != null);
			if (ok) {
				fitted[nscene] = pose;
				num_ok++;
			} else {
				// coast: keep the prediction as this scene's pose, continue the sequence
				fitted[nscene] = new double [][] {predicted[0].clone(), predicted[1].clone()};
				num_fail++;
				if (debugLevel > -4) {
					System.out.println("CuasPoseRT: scene "+nscene+" ("+ts_name+") FAILED, reason="+
							fail_reason[0]+" ("+Interscene.getFailReason(fail_reason[0])+"), coasting prediction");
				}
			}
			// Collect measurement characterization slices for -POSE-RT-HYPER
			{
				final float [][] comps = new float [HYPER_COMPONENTS.length][num_pix];
				for (float [] c : comps) Arrays.fill(c, Float.NaN);
				final double [][] vxys = coord_motion_rslt[1]; // {dx,dy,strength} per tile or null
				final double [][] eig =  (coord_motion_rslt.length > 2) ? coord_motion_rslt[2] : null;
				if (vxys != null) {
					for (int i = 0; i < num_pix; i++) {
						if (vxys[i] != null) {
							comps[0][i] = (float) vxys[i][0];
							comps[1][i] = (float) vxys[i][1];
							comps[2][i] = (float) vxys[i][2];
							comps[3][i] = (float) Math.sqrt(vxys[i][0]*vxys[i][0] + vxys[i][1]*vxys[i][1]);
						}
						if ((eig != null) && (eig[i] != null)) { // {eig_x, eig_y, l0, l1}, l0 <= l1
							final double l0 = eig[i][2], l1 = eig[i][3];
							comps[4][i] = (float) Math.sqrt(l0);
							comps[5][i] = (float) Math.sqrt(l1);
							comps[6][i] = (l0 > 0) ? (float) Math.sqrt(l1/l0) : Float.NaN;
							double ang = Math.atan2(eig[i][1], eig[i][0]); // precise-axis direction
							if (ang < 0) ang += Math.PI;                   // fold to [0, PI)
							comps[7][i] = (float) ang;
						}
					}
				}
				hyper_scenes.add(comps);
				hyper_ts.add(ts_name);
			}
			// DEBUG: render this scene's PD correlations into the -POSE-RT-CORR2D stack // By Claude on 07/04/2026
			if (stack_corr != null) {
				if (corr_pd_holder[0] != null) {
					final double [][] dbg_corr = ImageDtt.corr_partial_dbg(
							new double [][][] {corr_pd_holder[0]},   // [layer][tile][15*15]
							tilesX,                                  // tilesX
							corr_size,                               // corr_size (15)
							clt_parameters.corr_border_contrast,     // border_contrast
							debugLevel);                             // globalDebugLevel
					final float [] fslice = new float [dbg_corr[0].length];
					for (int i = 0; i < fslice.length; i++) fslice[i] = (float) dbg_corr[0][i];
					stack_corr.addSlice(ts_name, fslice);
				} else {
					stack_corr.addSlice(ts_name, corr_nan_slice.clone());
				}
			}
			// DEBUG: the composite scene render for -POSE-RT-COMPOSITE // By Claude on 07/04/2026
			if (img_scenes != null) {
				img_scenes.add((img_holder[0] != null) ? img_holder[0] : img_nan_slice.clone());
			}
			// CSV row + comparison stats
			final double [] st = (stored[nscene] != null) ? stored[nscene][1] : null;
			sb.append(nscene+", "+ts_name);
			for (int j = 0; j < 3; j++) sb.append(String.format(", %.6f", predicted[1][j]));
			for (int j = 0; j < 3; j++) sb.append(String.format(", %.6f", fitted[nscene][1][j]));
			for (int j = 0; j < 3; j++) sb.append((st != null) ? String.format(", %.6f", st[j]) : ", NaN");
			for (int j = 0; j < 3; j++) {
				double d = (st != null) ? (fitted[nscene][1][j] - st[j]) : Double.NaN;
				sb.append((st != null) ? String.format(", %.6f", d) : ", NaN");
				if (ok && (st != null)) {
					sum2_d[j] += d * d;
					if (Math.abs(d) > max_d[j]) max_d[j] = Math.abs(d);
				}
			}
			if (ok && (stored[nscene] != null)) num_cmp++;
			sb.append(String.format(", %.6f, %.1f, %.0f, %d\n",
					ok ? lma_rms[0] : Double.NaN, lma_rms[2], lma_rms[3], fail_reason[0]));
			// Scene identification line - attributes the preceding (unlabeled) LMA iteration
			// prints from adjustPairsLMAInterscene to this scene (index of N + timestamp)
			if (debugLevel > -4) {
				StringBuffer sl = new StringBuffer();
				sl.append("CuasPoseRT scene "+nscene+" (of "+quadCLTs.length+") "+ts_name+
						(ok ? " Done." : " FAILED (coasted).")+
						String.format(" RMS=%.4f, weight=%.1f, num=%.0f", lma_rms[0], lma_rms[2], lma_rms[3]));
				// one-line verbosity (Andrey 07/13): cycles + uniform MB vector fold into the
				// scene line (the separate trace/MB lines are now pose_lma_debug >= 1 only)
				if (ncyc_scene[0] > 0) sl.append(", cyc="+ncyc_scene[0]);
				if ((mb_vectors_scene != null) && clt_parameters.curt.pose_mb_uniform) {
					sl.append(String.format(", mb=(%.1f, %.1f)", mb_vectors_scene[0][0], mb_vectors_scene[1][0]));
				}
				if (st != null) {
					sl.append(" dstored[pix]=[");
					for (int j = 0; j < 3; j++) {
						sl.append(String.format((j > 0 ? ", %7.4f" : "%7.4f"),
								(fitted[nscene][1][j] - st[j]) * px_per_rad[j]));
					}
					sl.append("]");
				}
				System.out.println(sl.toString());
			}
			// zero-order prediction for the next scene
			prev_pose = predicted;
			prev_ts = ts;
			predicted = new double [][] {fitted[nscene][0].clone(), fitted[nscene][1].clone()};
			// pose_corr kernel-test run (roadmap 3-C rung 1): the case is written once the
			// first fitted scene completes - the exclusive diagnostic needs no more scenes
			// (this run's CSV/products are partial and disposable). By Claude on 07/13/2026.
			if (PoseCorrExport.captureDone()) {
				System.out.println("CuasPoseRT: pose_corr kernel-test case captured after scene "+
						nscene+" ("+ts_name+") - stopping the sequence early");
				break;
			}
		}

		// ---- Output ----
		final String csv_path = center_CLT.getX3dDirectory(true) + Prefs.getFileSeparator() +
				center_CLT.getImageName() + "-POSE-RT-TEST.csv";
		try (FileWriter fw = new FileWriter(csv_path)) {
			fw.write(sb.toString());
			System.out.println("CuasPoseRT.testPoseSequence(): saved "+csv_path);
		} catch (IOException e) {
			System.out.println("CuasPoseRT.testPoseSequence(): FAILED to save "+csv_path+": "+e.getMessage());
		}
		// reliable_tiles selection mask (1=selected), same grid as the DSI
		{
			final float [] frel = new float [num_pix];
			for (int i = 0; i < num_pix; i++) frel[i] = reliable_ref[i] ? 1.0f : 0.0f;
			final ImagePlus imp_rel = new ImagePlus(center_CLT.getImageName()+"-POSE-RT-RELIABLE",
					new FloatProcessor(tilesX, tilesY, frel));
			center_CLT.saveImagePlusInModelDirectory(null, imp_rel); // title as filename
		}
		// -POSE-RT-TILE-CALIB calibration (was -POSE-RT-MAXDXY): per-tile max-over-scenes residual, +inf where any
		// scene was NaN (auto-reject and mergeable across runs by simple max), NaN where
		// not selected/measured. Saved only from a FULL-selection run so a filtered run
		// never shrinks the calibration coverage. The derived boolean selection is also
		// saved (-POSE-RT-RELIABLE-FILT) for visualization.
		if (full_selection && !clt_parameters.curt.pose_full && !hyper_scenes.isEmpty()
				&& !PoseCorrExport.captureDone()) { // pose_full: never write the calibration // By Claude on 07/04/2026; a pose_corr capture stops the sequence early - a truncated max-over-scenes calibration must never be saved // By Claude on 07/13/2026
			final float [] fmax = new float [num_pix];
			Arrays.fill(fmax, Float.NaN);
			for (int i = 0; i < num_pix; i++) if (reliable_ref[i]) {
				float mx = 0f;
				boolean has = false, has_nan = false;
				for (float [][] comps : hyper_scenes) {
					final float d = comps[3][i]; // dxy
					if (Float.isNaN(d)) has_nan = true;
					else { has = true; if (d > mx) mx = d; }
				}
				// NaN (not +inf) where any scene was NaN: deriveSelection rejects NaN and +inf
				// identically (non-finite), and NaN keeps the saved TIFF viewable in ImageJ
				// (+inf breaks min/max autoscaling). Unmeasured/unselected tiles are also NaN.
				fmax[i] = (has_nan || !has) ? Float.NaN : mx;
			}
			center_CLT.saveImagePlusInModelDirectory(null, new ImagePlus(
					center_CLT.getImageName()+TILE_CALIB_SUFFIX, new FloatProcessor(tilesX, tilesY, fmax)));
			if (saved_calib != null) saved_calib[0] = true; // triggers the bootstrap filtered re-run // By Claude on 07/05/2026
			final boolean [] keep = deriveSelection(
					fmax,
					tilesX,
					clt_parameters.curt.pose_dxy_k,
					clt_parameters.curt.pose_num_tiles,
					clt_parameters.curt.pose_shrink, // interior gate (3-A3) // By Claude on 07/13/2026
					debugLevel);
			final float [] fkeep = new float [num_pix];
			for (int i = 0; i < num_pix; i++) fkeep[i] = keep[i] ? 1.0f : 0.0f;
			center_CLT.saveImagePlusInModelDirectory(null, new ImagePlus(
					center_CLT.getImageName()+"-POSE-RT-RELIABLE-FILT", new FloatProcessor(tilesX, tilesY, fkeep)));
		}
		// -POSE-RT-CORR2D (DEBUG): per-scene pixel-domain correlations vs the virtual center // By Claude on 07/04/2026
		if ((stack_corr != null) && (stack_corr.getSize() > 0)) {
			final ImagePlus imp_corr = new ImagePlus(center_CLT.getImageName()+"-POSE-RT-CORR2D", stack_corr);
			imp_corr.getProcessor().resetMinAndMax();
			center_CLT.saveImagePlusInModelDirectory(null, imp_corr); // title as filename
		}
		// -POSE-RT-COMPOSITE (DEBUG): hyperstack [t: transformed-real, virtual][z: scene] -
		// the composite (TD-averaged, grid-transformed) scene actually correlated and the
		// virtual-center render it correlates against (same for all scenes, repeated so the
		// component slider blink-compares at any scene). Same z as -POSE-RT-HYPER/-CORR2D.
		// By Claude on 07/04/2026, per Andrey's layout.
		if ((img_scenes != null) && !img_scenes.isEmpty()) {
			final ImageStack stack_img = new ImageStack(img_width, img_height);
			for (int ns = 0; ns < img_scenes.size(); ns++) {
				stack_img.addSlice("real:"+hyper_ts.get(ns), img_scenes.get(ns));
			}
			for (int ns = 0; ns < img_scenes.size(); ns++) {
				stack_img.addSlice("virtual:"+hyper_ts.get(ns), ref_render);
			}
			final ImagePlus imp_img = new ImagePlus(center_CLT.getImageName()+"-POSE-RT-COMPOSITE", stack_img);
			imp_img.setDimensions(1, img_scenes.size(), 2); // z=scenes, t=[real, virtual]
			imp_img.setOpenAsHyperStack(true);
			imp_img.getProcessor().resetMinAndMax();
			center_CLT.saveImagePlusInModelDirectory(null, imp_img); // title as filename
		}
		// -POSE-RT-HYPER: z = scenes (top slider), t = components (bottom slider) -
		// same layout as renderSceneSequence make_hyper (component is the outer slice order)
		if (!hyper_scenes.isEmpty()) {
			final ImageStack stack_hyper = new ImageStack(tilesX, tilesY);
			for (int nc = 0; nc < HYPER_COMPONENTS.length; nc++) {
				for (int ns = 0; ns < hyper_scenes.size(); ns++) {
					stack_hyper.addSlice(HYPER_COMPONENTS[nc]+":"+hyper_ts.get(ns), hyper_scenes.get(ns)[nc]);
				}
			}
			final ImagePlus imp_hyper = new ImagePlus(center_CLT.getImageName()+"-POSE-RT-HYPER", stack_hyper);
			imp_hyper.setDimensions(1, hyper_scenes.size(), HYPER_COMPONENTS.length);
			imp_hyper.setOpenAsHyperStack(true);
			imp_hyper.getProcessor().resetMinAndMax();
			center_CLT.saveImagePlusInModelDirectory(null, imp_hyper); // title as filename
		}
		System.out.println("CuasPoseRT.testPoseSequence(): scenes OK="+num_ok+", failed(coasted)="+num_fail+
				", compared="+num_cmp);
		if (num_cmp > 0) {
			final String [] names = {"azimuth", "tilt", "roll"};
			for (int j = 0; j < 3; j++) {
				final double rms_rad = Math.sqrt(sum2_d[j]/num_cmp);
				System.out.println(String.format(
						"  fitted-stored %8s: RMS=%7.4f pix, max=%7.4f pix   (%8.4f mrad, max %8.4f mrad)",
						names[j], rms_rad*px_per_rad[j], max_d[j]*px_per_rad[j],
						1000*rms_rad, 1000*max_d[j]));
			}
			System.out.println(String.format(
					"  (pixel scales: az/tilt %.1f pix/rad = focal/pixelSize, roll %.1f pix/rad = distortionRadius/pixelSize)",
					px_per_rad_at, px_per_rad_r));
		}
		return fitted;
	}
}
