/**
 **
 ** 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.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       debugLevel) {
		final boolean [] sel = new boolean [fmax.length];
		final int tilesY = fmax.length / tilesX;
		float [] finite = new float [fmax.length];
		int num_finite = 0;
		for (float f : fmax) if (!Float.isNaN(f) && !Float.isInfinite(f)) finite[num_finite++] = f;
		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] = !Float.isNaN(fmax[i]) && !Float.isInfinite(fmax[i]) && (fmax[i] <= gate);
			if (eligible[i]) num_gated++;
		}
		if (debugLevel > -4) {
			System.out.println(String.format(
					"CuasPoseRT.deriveSelection(): finite=%d, median=%.3f, NMAD=%.3f, gate=%.3f (k=%.2f) -> %d gated tiles",
					num_finite, 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
	 *   getCltData -> CuasTD.consolidateSensorsTD -> setCltData(slot 0)  - the CPU bridge
	 *   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); only the bridge is CPU, to be
	 * replaced by the clt_average_sensors kernel later. 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();
		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;
			}
			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;
			}
			// 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 CPU consolidation bridge (future clt_average_sensors kernel).
		// 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.
		final float [][] fclt16 = gpuQuad.getCltData(false);
		final float [] avg_td = CuasTD.consolidateSensorsTD(fclt16, null);
		gpuQuad.setCltData(0, avg_td, false);
		// 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);
		// 5. TD tiles -> FZ-normalize -> pixel domain (the JNA-validated TDCorrTile path)
		final TDCorrTile [] td_tiles = TDCorrTile.getFromGpu(gpuQuad);
		final double [][] corr_pd = TDCorrTile.convertTDtoPD(
				gpuQuad,
				td_tiles,
				0xFE,                           // corr_type (sum slot, as CuasMotion)
				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;
		}
		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)
		final int corr_width = 2 * GPUTileProcessorDttSize() - 1;
		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;
			final double [] r = Correlation2d.getMaxXYCmEig(
					corr_pd[i],                       // will not be modified (fpn_mask == null)
					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")
					0.0,                              // eig_sub_frac (select only)
					null,                             // fpn_mask (input is FPN-subtracted)
					false,                            // ignore_border
					null,                             // debug_data
					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;
	}

	/**
	 * Appropriate the borrowed center reference: subtract the DC level from the
	 * reference CLT already uploaded to the GPU, so it matches the zero-sky conditioned
	 * scenes (the MCLT has no clean DC/AC separation - a DC constant spreads over many
	 * TD points and costs float32 mantissa bits; LWIR is ADDITIVE: day/night and seasons
	 * shift the level, they do not scale it - the borrowed center reflects its own
	 * footage vintage).
	 * Level: curt.dc_center; 0 = AUTO - median of the top (sky) half of the borrowed
	 * center average image, then ADOPTED into curt.dc_center (persisted on the next
	 * configuration save - borrowable later, like the photometric calibration).
	 * Mechanics: the CLT of a UNIFORM image (the DC pattern - identical for every
	 * converted tile) is obtained through the existing back-propagation convert
	 * (setImageCenter + no_kernels + use_center_image, the CorrectionFPN recipe), then
	 * fclt_zeroed = fclt - level*pattern is re-uploaded to all reference slots. Only the
	 * task tiles get the pattern (erase=NaN elsewhere) - sufficient: the correlation
	 * reads task tiles only. Failure-safe: any missing piece leaves the reference as-is.
	 * By Claude on 07/05/2026, from Andrey's design.
	 */
	public static void appropriateCenterDC(
			final CLTParameters clt_parameters,
			final QuadCLT       center_CLT,
			final TpTask []     tp_tasks_center,
			final float []      fclt0,
			final int           debugLevel) {
		final GpuQuad gpuQuad = center_CLT.getGPUQuad();
		double level = clt_parameters.curt.dc_center;
		if (level == 0.0) { // auto: median of the top (sky) half of the borrowed center average
			final ImagePlus imp_avg = center_CLT.getCenterAverage();
			if (imp_avg == null) {
				System.out.println("appropriateCenterDC(): no center average image - reference NOT offset");
				return;
			}
			final float [] apx = (float []) imp_avg.getProcessor().getPixels();
			final int width = imp_avg.getWidth();
			final int top_pix = (imp_avg.getHeight() / 2) * width;
			final float [] samples = new float [top_pix];
			int n = 0;
			for (int i = 0; i < top_pix; i++) if (!Float.isNaN(apx[i])) samples[n++] = apx[i];
			if (n == 0) {
				System.out.println("appropriateCenterDC(): center average all-NaN in the sky half - reference NOT offset");
				return;
			}
			Arrays.sort(samples, 0, n);
			level = samples[n/2];
			clt_parameters.curt.dc_center = level; // adopt: persisted on the next config save
			System.out.println("appropriateCenterDC(): AUTO center DC level = "+level+
					" (median of the sky half of the borrowed center average), adopted into curt.dc_center");
		}
		// the DC pattern: CLT of a uniform image through the back-propagation convert
		final int [] wh = center_CLT.getGeometryCorrection().getSensorWH();
		final double [][] saved_center = center_CLT.getImageCenter();
		float [] pattern = null;
		try {
			final double [][] uniform = new double [][] {new double [wh[0]*wh[1]]};
			Arrays.fill(uniform[0], 1.0);
			center_CLT.setImageCenter(uniform);
			final ImageDtt image_dtt = 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());
			image_dtt.preSetReferenceTD( // tasks + offsets, no convert
					clt_parameters.img_dtt,
					tp_tasks_center,
					true,                       // keep_tiles_offsets
					clt_parameters.gpu_sigma_r,
					clt_parameters.gpu_sigma_b,
					clt_parameters.gpu_sigma_g,
					clt_parameters.gpu_sigma_m,
					debugLevel);
			image_dtt.execConvertDirect(
					false,                      // use_reference_buffer: SCENE buffer (scratch)
					null,                       // wh
					1,                          // erase_clt: NaN outside tasks
					true,                       // no_kernels
					true);                      // use_center_image (uniform)
			pattern = gpuQuad.getCltData(false)[0];
		} catch (Exception e) {
			System.out.println("appropriateCenterDC(): DC-pattern conversion FAILED ("+e.getMessage()+
					") - reference NOT offset");
			pattern = null;
		} finally {
			center_CLT.setImageCenter(saved_center); // never leave back-propagate mode on
		}
		if (pattern == null) return;
		final float [] fclt_zeroed = fclt0.clone();
		int n_off = 0;
		for (int i = 0; i < fclt_zeroed.length; i++) {
			final float p = pattern[i];
			if (!Float.isNaN(p) && !Float.isNaN(fclt_zeroed[i])) {
				fclt_zeroed[i] -= (float) (level * p);
				n_off++;
			}
		}
		for (int ncam = 0; ncam < center_CLT.getNumSensors(); ncam++) {
			gpuQuad.setCltData(ncam, fclt_zeroed, true); // reference buffer, all slots (merge_channels convention)
		}
		System.out.println("appropriateCenterDC(): reference CLT offset by "+level+
				" over "+n_off+" TD points (task tiles)");
	}

	/**
	 * 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           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;
		int nlma = 0;
		for (; nlma < clt_parameters.imp.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) 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
			final int lmaResult = intersceneLma.runLma(
					clt_parameters.ilp.ilma_lambda,
					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,
					clt_parameters.imp.max_LMA,
					false,                            // last_run
					debugLevel);
			if (lmaResult < 0) {
				System.out.println("leanFitScene(): LMA failed, lmaResult="+lmaResult);
				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);
			if ((diffs_atr[0] < clt_parameters.imp.exit_change_atr) &&
					(diffs_xyz[0] < clt_parameters.imp.exit_change_xyz)) {
				break;
			}
		}
		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)");
		}
		return scene_xyzatr0;
	}

	/**
	 * Re-generate per-scene poses against the virtual-center reference, RT-style.
	 *
	 * @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 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]);
		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.
		ImagePlus imp_max = (clt_parameters.curt.pose_recalc || clt_parameters.curt.pose_full) ? null :
				center_CLT.readImagePlusFromModelDirectory(TILE_CALIB_SUFFIX);
		if ((imp_max == null) && !clt_parameters.curt.pose_recalc && !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,
					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)");
		} else {
			System.out.println("CuasPoseRT.testPoseSequence(): "+
					(clt_parameters.curt.pose_recalc ? "curt_pose_recalc ON" : TILE_CALIB_SUFFIX+" not found")+
					" - FULL run, will generate the calibration");
		}
		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
		// APPROPRIATE the borrowed center: offset the reference CLT so it matches the
		// zero-sky conditioned scenes (curt.dc_center; 0 = auto from the borrowed center
		// average, adopted for persistence). Firewall: the borrowed data is zeroed ONCE
		// here at the boundary - nothing downstream compensates again. Failure-safe: any
		// missing piece leaves the reference unchanged (a warning, not a break).
		// By Claude on 07/05/2026, from Andrey's design.
		appropriateCenterDC(clt_parameters, center_CLT, tp_tasks_center[0], fclt[0], debugLevel);

		// ---- 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};

		// ---- 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;
		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)
						null,                       // Config (defaults: rowcol + photometric + FPN)
						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) {
				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 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
						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]));
				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()};
		}

		// ---- 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()) { // pose_full: never write the calibration // By Claude on 07/04/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)));
			final boolean [] keep = deriveSelection(
					fmax,
					tilesX,
					clt_parameters.curt.pose_dxy_k,
					clt_parameters.curt.pose_num_tiles,
					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;
	}
}
