/**
 ** CuasRT.java - the single entry point of the CUAS RT (real-time) mode
 **
 ** Copyright (C) 2026 Elphel, Inc.
 **
 ** -----------------------------------------------------------------------------**
 **
 **  CuasRT.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.File;        // By Claude on 07/12/2026
import java.io.IOException; // By Claude on 07/12/2026
import java.util.Arrays;

import com.elphel.imagej.cuas.CuasMotion;
import com.elphel.imagej.gpu.testdata.AvgTdExport; // By Claude on 07/12/2026
import com.elphel.imagej.cuas.CuasRanging;
import com.elphel.imagej.gpu.GpuQuad;
import com.elphel.imagej.gpu.TpTask;
import com.elphel.imagej.ims.UasLogReader;
import com.elphel.imagej.cameras.CLTParameters;
import com.elphel.imagej.tileprocessor.ImageDtt;
import com.elphel.imagej.tileprocessor.Interscene;
import com.elphel.imagej.tileprocessor.OpticalFlow;
import com.elphel.imagej.tileprocessor.QuadCLT;
import com.elphel.imagej.tileprocessor.TwoQuadCLT;

import ij.ImagePlus;

/**
 * The SINGLE entry point of the CUAS RT mode. OpticalFlow.buildSeries() delegates
 * here on (curt.en && hasCenterClt) and knows nothing else about RT.
 *
 * Top-level flow (keep {@link #run} READABLE - it is the map of the RT mode):
 *   1. INITIALIZATION - transform the borrowed data (center ingest, per-scene
 *      instances) and fix missing calibration (per-sensor photometric).
 *   2. DIAGNOSTICS  - optional test/prototype stages (pose_test / rend_test /
 *      cond_test). The POSE stage is part of the RT cycle and normally always
 *      runs (Andrey 07/06/2026); the render product follows it when enabled;
 *      cond_test is standalone. They replace the production detection and
 *      return; they must never leak into the production flow nor grow the top.
 *   3. PRODUCTION   - the per-input(scene) processing loop and target detection.
 *      MVP target shape: for each input scene, a multi-threaded read of the 16
 *      sensor /jp4/*.tiff (or their hardware-stream equivalents later) ->
 *      CuasConditioning -> GPU -> pose -> detection. Today the detection still
 *      consumes the bulk oracle product built in INITIALIZATION - replace
 *      per-piece without changing this top structure.
 *
 * Moved from OpticalFlow.buildSeries() (was inline at the curt_en branch)
 * by Claude on 07/05/2026, per Andrey's structure ruling.
 */
public class CuasRT {

	/** See the class comment: initialization -> optional diagnostics -> production. */
	public static void run(
			final CLTParameters clt_parameters,
			final QuadCLT       master_CLT,   // virtual "-CENTER" (borrowed-data owner)
			final QuadCLT []    quadCLTs,     // per-scene instances (built during initialization)
			final QuadCLT       quadCLT_main, // top-menu parameter-transfer instance / new-instance template
			final int           ref_index,
			final UasLogReader  uasLogReader,
			final boolean       batch_mode,
			final int           debugLevel) {
		System.out.println("===== Running CUAS RT detection (curt_en). =====");
		// ---- 1. INITIALIZATION: borrowed data + calibration ----
		final CuasRanging cuasRanging = new CuasRanging(
				clt_parameters, // CLTParameters     clt_parameters,
				master_CLT,     // QuadCLT           center_CLT,
				quadCLTs,       // QuadCLT []        scenes,
				debugLevel);    // int               debugLevel
		final ImagePlus imp_targets = ingest(cuasRanging);
		calibratePhotometric(clt_parameters, master_CLT, quadCLTs, quadCLT_main, ref_index, debugLevel);
		appropriateCenter(clt_parameters, master_CLT, debugLevel);
		// ---- 2. DIAGNOSTICS (optional, exclusive - replace production and return) ----
		if (diagnostics(clt_parameters, master_CLT, quadCLTs, debugLevel)) {
			return;
		}
		// ---- 3. PRODUCTION: per-scene processing + target detection ----
		detect(clt_parameters, cuasRanging, master_CLT, imp_targets, uasLogReader, batch_mode, debugLevel);
	}

	/**
	 * Borrowed-data ingest. Today: the GPU oracle generator CuasRanging.prepareFpixels()
	 * (explicit here, CUDA-sensitive - may be incompatible with a future CUDA; the result
	 * is a plain ImagePlus handed to the CUDA-free detection). It also builds the per-scene
	 * QuadCLT instances = the borrowed-calibration source. MVP replacement: per-scene raw
	 * /jp4/ ingest (CuasConditioning.conditionSceneToGpu) inside the production loop, with
	 * only the center borrow remaining here. By Claude on 06/24/2026 (moved 07/05/2026).
	 */
	private static ImagePlus ingest(final CuasRanging cuasRanging) {
		return cuasRanging.prepareFpixels();
	}

	/**
	 * Per-sensor photometric (re)calibration (curt.calib): fit a+b*x over safe (weak/far)
	 * tiles, fold into the 16+16 lwir offsets/scales, apply IN MEMORY ONLY (master_CLT +
	 * reference scene + quadCLT_main + the ALREADY-SPAWNED per-scene instances).
	 * Idempotent (a~0, b~1 when already calibrated). Persistence is deliberate and
	 * user-controlled: save the corr-xml configuration after the run, then propagate
	 * between configs with the compare/combine GUI (Andrey's workflow, 07/05/2026) -
	 * INTERFRAME is QuadCLT-workflow/POSES ONLY, never photometric. A stale config with
	 * calib OFF re-imposes the old photometric on every fresh session (POSE-09 lesson:
	 * 1470-count per-sensor sky spread vs 17 calibrated).
	 * By Claude on 07/03/2026 (moved 07/05/2026).
	 */
	private static void calibratePhotometric(
			final CLTParameters clt_parameters,
			final QuadCLT       master_CLT,
			final QuadCLT []    quadCLTs,
			final QuadCLT       quadCLT_main,
			final int           ref_index,
			final int           debugLevel) {
		if (!clt_parameters.curt.calib) {
			return;
		}
		CuasMotion.rtPhotometricCalibration(
				clt_parameters,       // CLTParameters clt_parameters,
				master_CLT,           // QuadCLT       master_CLT (combo DSI source, in-memory apply)
				quadCLTs[ref_index],  // QuadCLT       ref_scene (photometric owner, INTERFRAME corr-xml)
				quadCLT_main,         // QuadCLT       quadCLT_main (main config + new-instance template)
				quadCLTs,             // QuadCLT []    scenes (already spawned by the ingest above)
				clt_parameters.curt.cond_test, // boolean save_stacks
				ImageDtt.THREADS_MAX, // int           threadsMax,
				debugLevel);          // int           debugLevel
	}

	private static final double [] ZERO3 = {0.0, 0.0, 0.0};

	/**
	 * APPROPRIATE the borrowed center (the borrow boundary - the ONLY place the RT flow
	 * transforms the borrowed QuadCLT data): offset the center CLT so it matches the
	 * zero-sky conditioned scenes, IN MEMORY (master_CLT.setCenterClt) - everything
	 * downstream (pose test, render, lean measure) then sees an already-zeroed center and
	 * never compensates again. The -CLT-UPDATED disk save happens upstream of CuasRT.run,
	 * so the zeroed copy never persists.
	 *
	 * Rationale (doc SS5a): 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, never scale it) - the borrowed center reflects its own
	 * footage vintage.
	 * Level: curt.dc_center nonzero = manual override (global config); 0 = AUTO - the
	 * per-sequence value from <center>-RT-STATE.json, derived when absent as the median of
	 * the top (sky) half of the reference buffer render itself (the exact data being
	 * zeroed) and stored back there. Erase the file/field to force a re-derive; the
	 * corr-xml config is never mutated.
	 * Mechanics: full-grid conditioned-disparity tasks (the ONE RT disparity source) ->
	 * setReferenceGPU (raw center up, tasks built) -> AUTO level from the reference render
	 * -> DC pattern = CLT of a UNIFORM image through the back-propagation convert
	 * (setImageCenter on the GPU-ASSOCIATED QuadCLT + no_kernels + use_center_image, the
	 * CorrectionFPN recipe) -> the PURE kernel CuasConditioning.zeroDcClt (knows no
	 * QuadCLT) -> store back in memory + re-upload the reference slots. Failure-safe: any
	 * missing piece leaves the center as-is (a warning, not a break).
	 * By Claude on 07/05/2026, from Andrey's design (moved here from CuasPoseRT per the
	 * borrow-boundary ruling).
	 */
	private static void appropriateCenter(
			final CLTParameters clt_parameters,
			final QuadCLT       center_CLT,
			final int           debugLevel) {
		final float [] fclt0 = center_CLT.getCenterClt();
		if (fclt0 == null) {
			System.out.println("appropriateCenter(): no center CLT - center NOT offset");
			return;
		}
		final GpuQuad gpuQuad = center_CLT.getGPUQuad();
		// full-grid tasks from the ONE conditioned disparity source (same as render/pose)
		final boolean is_aux = center_CLT.isAux();
		final double [] center_disparity = CuasRender.conditionDisparityCuas(
				center_CLT.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 [] all_tiles = new boolean [center_disparity.length];
		for (int i = 0; i < all_tiles.length; i++) all_tiles[i] = !Double.isNaN(center_disparity[i]);
		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 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,
				new float [][] {fclt0}, // final float [][] fclt,
				all_tiles,        // final boolean []   selection (FULL grid - the appropriation covers every tile)
				clt_parameters.imp.margin,            // final int margin,
				clt_parameters.imp.mb_tau,            // double    mb_tau,
				clt_parameters.imp.mb_max_gain_inter, // double    mb_max_gain,
				null,             // double [][]        mb_vectors (center does not move)
				debugLevel);      // int                debug_level
		// Level resolution (Andrey's persistence split, 07/05/2026): a NONZERO curt.dc_center
		// = manual override from the global corr-xml config; 0 = AUTO - reuse the
		// PER-SEQUENCE value from <center>-RT-STATE.json, deriving and storing it there when
		// absent (the missing-file rule: erase the file/field to force a re-derive). The
		// config is never mutated. By Claude on 07/05/2026.
		double level = clt_parameters.curt.dc_center;
		CuasRtState rt_state = null;
		if (level == 0.0) { // auto: per-sequence persisted value first
			rt_state = CuasRtState.load(center_CLT.getX3dDirectory(), center_CLT.getImageName());
			if (rt_state.dc_center != null) {
				level = rt_state.dc_center;
				System.out.println("appropriateCenter(): center DC level = "+level+" (reused from "+
						CuasRtState.SUFFIX+": "+rt_state.dc_center_source+"); delete "+
						center_CLT.getImageName()+CuasRtState.SUFFIX+" (or its dc_center field) to re-derive");
			}
		}
		if (level == 0.0) { // derive: median of the top (sky) half of the reference buffer render
			// Level from the reference render ITSELF (the exact data being zeroed),
			// just uploaded by setReferenceGPU above. By Claude on 07/05/2026.
			final float [] apx = CuasMotion.perSensorImagesFromTD(gpuQuad, true)[0];
			if (apx == null) {
				System.out.println("appropriateCenter(): reference render unavailable - center NOT offset");
				return;
			}
			final int width = gpuQuad.getImageWidth();     // valid after the imclt above
			final int top_pix = (gpuQuad.getImageHeight() / 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("appropriateCenter(): reference render all-NaN in the sky half - center NOT offset");
				return;
			}
			Arrays.sort(samples, 0, n);
			level = samples[n/2];
			rt_state.dc_center = level;
			rt_state.dc_center_source = "auto: sky-half median of the reference buffer render";
			rt_state.save(center_CLT.getX3dDirectory(), center_CLT.getImageName());
			System.out.println("appropriateCenter(): AUTO center DC level = "+level+
					" (median of the sky half of the reference buffer render), stored in "+CuasRtState.SUFFIX);
		}
		// the DC pattern: CLT of a uniform image through the back-propagation convert.
		// The uniform image MUST be set on the GpuQuad's ASSOCIATED QuadCLT: both backends
		// read gpuQuad.getQuadCLT().getImageCenter() (GpuQuadJna.setBayerImages(center=true),
		// JCuda base alike) - the CorrectionFPN recipe sets it there too. Setting it on
		// center_CLT reads a null/stale center -> pattern ~0 -> silent no-op (POSE-09).
		// By Claude on 07/05/2026.
		final QuadCLT gpu_CLT = gpuQuad.getQuadCLT();
		final int [] wh = center_CLT.getGeometryCorrection().getSensorWH();
		final double [][] saved_center = gpu_CLT.getImageCenter();
		float [] pattern = null;
		try {
			final double [][] uniform = new double [][] {new double [wh[0]*wh[1]]};
			Arrays.fill(uniform[0], 1.0);
			gpu_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[0],
					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("appropriateCenter(): DC-pattern conversion FAILED ("+e.getMessage()+
					") - center NOT offset");
			pattern = null;
		} finally {
			gpu_CLT.setImageCenter(saved_center); // never leave back-propagate mode on
		}
		if (pattern == null) return;
		// Guard against the silent-no-op class: a uniform-1.0 image converts to TD
		// coefficients of order ~1; an (effectively) empty GPU center buffer converts to
		// exact zeros. Warn loudly instead of "offsetting" by nothing. By Claude on 07/05/2026.
		float pattern_max = 0f;
		for (int i = 0; i < pattern.length; i++) {
			final float ap = Math.abs(pattern[i]);
			if (!Float.isNaN(ap) && (ap > pattern_max)) pattern_max = ap;
		}
		if (pattern_max < 1e-6) {
			System.out.println("appropriateCenter(): DC pattern is all-zero (max |p| = "+pattern_max+
					") - the uniform center image did not reach the GPU. Center NOT offset");
			return;
		}
		final float [] fclt_zeroed = CuasConditioning.zeroDcClt(fclt0, pattern, level); // the pure kernel
		center_CLT.setCenterClt(fclt_zeroed); // in-memory only: -CLT-UPDATED was saved upstream, raw stays on disk
		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("appropriateCenter(): borrowed center CLT zeroed in memory (level "+level+
				") - downstream consumers see the appropriated center only");
	}

	/**
	 * Resolve the PER-SEQUENCE DC level for scene conditioning. The fix for the
	 * rotation-synced DC flicker (POSE-10 forensics, 07/05/2026): a per-SCENE sky median
	 * oscillates with azimuth as warm terrain sweeps through the sky half - RT-RENDER
	 * horizon pk-pk 234 counts with the period exactly one camera revolution, and any
	 * temporal-average step downstream converts that wobble into content-correlated
	 * residuals ("real image treated as FPN" ghosts). ONE constant per sequence instead
	 * (Andrey's rule: from the average scene): manual curt.dc_center override when
	 * nonzero, else the RT-STATE value derived by {@link #appropriateCenter} from the
	 * borrowed center (which IS the sequence average) - so the scenes and the center
	 * shift by the IDENTICAL constant.
	 * @return the level, or NaN when neither source is available (conditioning then
	 *         falls back to the per-scene median - callers warn loudly).
	 * By Claude on 07/05/2026.
	 */
	public static double sequenceDcLevel(
			final CLTParameters clt_parameters,
			final QuadCLT       center_CLT) {
		if (clt_parameters.curt.dc_center != 0.0) return clt_parameters.curt.dc_center;
		final CuasRtState state = CuasRtState.load(center_CLT.getX3dDirectory(), center_CLT.getImageName());
		return (state.dc_center != null) ? state.dc_center : Double.NaN;
	}

	/**
	 * Optional diagnostic/prototype stages. NOT mutually exclusive anymore (Andrey
	 * 07/06/2026): the POSE stage is part of the RT cycle and normally ALWAYS runs
	 * (checkbox retained until the production loop consumes the poses directly); the
	 * render product runs AFTER it when also enabled. cond_test remains a standalone
	 * diagnostic (runs only when nothing else did). Keep new tests HERE - the
	 * production path and the top method must not grow.
	 * @return true if any stage ran (caller returns without production detection).
	 */
	private static boolean diagnostics(
			final CLTParameters clt_parameters,
			final QuadCLT       master_CLT,
			final QuadCLT []    quadCLTs,
			final int           debugLevel) {
		boolean ran = false;
		if (!"none".equals(clt_parameters.curt.kernel_test)) {
			// GPU-kernel oracle test (curt_kernel_test dropdown): EXCLUSIVE - one Java run
			// exports ONE kernel's testdata case; nothing else runs, so a saved config
			// can't accidentally mix a kernel test into other diagnostics/production.
			// By Claude on 07/12/2026, from Andrey's design.
			kernelTest(clt_parameters, master_CLT, debugLevel);
			return true;
		}
		if (clt_parameters.curt.pose_test) {
			// The POSE stage (part of the RT cycle): re-generate per-scene 3-angle poses
			// against the virtual-center reference, ascending, prediction-seeded, single
			// pass on the final combo DSI. Output: -POSE-RT-TEST.csv + fitted-vs-stored
			// summary (truth = scenes_poses restored from INTERFRAME corr-xml).
			// By Claude on 07/03/2026, from Andrey's design.
			System.out.println("===== CUAS RT pose stage (curt_pose_test): per-scene 3-angle fit vs virtual center =====");
			CuasPoseRT.testPoseSequence(
					clt_parameters, // CLTParameters clt_parameters,
					master_CLT,     // QuadCLT       center_CLT,
					quadCLTs,       // QuadCLT []    quadCLTs,
					debugLevel);    // int           debugLevel
			ran = true;
		}
		if (clt_parameters.curt.rend_test) {
			// Full-render product (after the pose stage when both are ON): per scene raw
			// /jp4/ -> conditionSceneToGpu -> virtual-grid render at BORROWED stored pose +
			// ERS rates -> -CUAS-RT-RENDER hyperstack, comparable slice-by-slice to the
			// -CUAS-INDIVIDUAL/MERGED-CUAS-DBG oracle products. By Claude on 07/05/2026,
			// non-exclusive per Andrey 07/06/2026.
			System.out.println("===== CUAS RT render (curt_rend_test): ingest+render sequence vs oracle DBG =====");
			CuasRender.testRenderSequence(
					clt_parameters, // CLTParameters clt_parameters,
					master_CLT,     // QuadCLT       center_CLT,
					quadCLTs,       // QuadCLT []    quadCLTs,
					debugLevel);    // int           debugLevel
			ran = true;
		}
		if (!ran && clt_parameters.curt.cond_test) {
			// Conditioning/calibration diagnostic: raw /jp4/ baseline (no photometric/FPN/
			// conditioning), saved as -CUAS-PERSENSOR-RAW for side-by-side compare with the
			// conditioned -CUAS-PERSENSOR (saved by the calibration step when curt.calib is
			// on, or rendered here otherwise). Both stacks use the same uniform sensor-domain
			// task grid - never leftover GPU task state. By Claude on 07/01/2026,
			// restructured 07/03/2026.
			System.out.println("===== CUAS RT conditioning test (curt_cond_test): per-sensor average spread =====");
			if (!clt_parameters.curt.calib) { // conditioned baseline not yet rendered by the calibration step
				QuadCLT cond_phys = master_CLT.getGPUQuad().getQuadCLT(); // physical scene bound to the GPU
				if (cond_phys != null) { // the actual image_data (bypass image_data_alt), same grid as raw
					CuasMotion.perSensorFromData(clt_parameters, master_CLT, cond_phys.getOrigImageData(),
							"-CUAS-PERSENSOR", ImageDtt.THREADS_MAX, debugLevel);
				}
			}
			CuasMotion.perSensorFromRawJp4(clt_parameters, master_CLT, ImageDtt.THREADS_MAX, debugLevel);
			// A2 step 2 validation: NaN-aware cross-sensor TD consolidation (CPU oracle of
			// the future clt_average_sensors kernel) checked against the imclt linearity
			// oracle: imclt(TD-average) must equal pixel-average of per-sensor imclt renders.
			// Uses the raw-jp4 16-sensor TD just built above. By Claude on 07/05/2026.
			CuasTD.validateConsolidation(
					master_CLT.getGPUQuad(), // GpuQuad with the current 16-sensor TD
					master_CLT,              // debug-stack save target (-CUAS-TDAVG-CHECK)
					debugLevel);
			ran = true;
		}
		return ran;
	}

	/**
	 * GPU-kernel oracle test dispatch (curt.kernel_test dropdown - one kernel per
	 * Java run, Andrey 07/12/2026). For the selected kernel: build/borrow its live
	 * inputs, run the CPU oracle, and export a self-describing testdata case
	 * (<model>/testdata/<kernel>) for the standalone NSight-debuggable CUDA test
	 * (tile_processor_gpu: tests_bin/test_<kernel> --data <case> --tol 0). When the
	 * C++ test already ran on this case, its results/ are imported and compared
	 * Java-side too (round-trip). Adding a kernel = one name in
	 * CuasRtParameters.KERNEL_TESTS + one case here. By Claude on 07/12/2026.
	 */
	private static void kernelTest(
			final CLTParameters clt_parameters,
			final QuadCLT       master_CLT,
			final int           debugLevel) {
		final String kernel = clt_parameters.curt.kernel_test;
		System.out.println("===== CUAS RT GPU-kernel test (curt_kernel_test): "+kernel+" =====");
		final GpuQuad gpuQuad = master_CLT.getGPUQuad();
		final QuadCLT phys = gpuQuad.getQuadCLT(); // physical scene bound to the GPU: conditioned data + geometry
		if (phys == null) {
			System.out.println("kernelTest("+kernel+"): no physical scene bound to the GPU - skipping");
			return;
		}
		final String dir_path = new File(new File(master_CLT.getX3dDirectory(), "testdata"), kernel).getPath();
		try {
			switch (kernel) {
			case "avg_td": {
				// clt_average_sensors (roadmap 2c): convert the bound scene's conditioned
				// image_data with the uniform calibration task grid (identical to the
				// cond_test/calib conversion), run the CPU oracle
				// (CuasTD.consolidateSensorsTD) and export the case including per-sensor
				// TpTask XY - the input of the OOB soft/hard-margin priority stage.
				final TpTask [][] tasks_out = new TpTask [1][];
				if (!CuasMotion.convertFromData(clt_parameters, gpuQuad, phys.getOrigImageData(),
						"-KERNEL-TEST-AVG-TD", tasks_out, ImageDtt.THREADS_MAX, debugLevel)) {
					return;
				}
				AvgTdExport.export(gpuQuad, false, tasks_out[0], dir_path, phys.getImageName(), debugLevel);
				CuasTD.validateConsolidation(gpuQuad, master_CLT, debugLevel); // imclt-linearity check + counts stats
				break;
			}
			case "avg_td_oob": {
				// clt_average_sensors v1 (roadmap 2c): OOB source-coordinate priority.
				// Same conversion as avg_td; tasks handed over in the [2][] shape of the
				// future index_consolidate contract (set 1 = MB pair, null here - the
				// calibration-grid convert is single-pass; the real MB-pair export comes
				// with the RT-chain wiring). Soft/hard margins travel in the manifest
				// (frozen spec @ROADMAP 2c). By Claude on 07/12/2026.
				final TpTask [][] tasks_out = new TpTask [1][];
				if (!CuasMotion.convertFromData(clt_parameters, gpuQuad, phys.getOrigImageData(),
						"-KERNEL-TEST-AVG-TD-OOB", tasks_out, ImageDtt.THREADS_MAX, debugLevel)) {
					return;
				}
				AvgTdExport.exportOob(gpuQuad, false, new TpTask [][] {tasks_out[0], null},
						clt_parameters.curt.oob_soft, clt_parameters.curt.oob_hard, // dialog params (defaults = frozen 12/8) // By Claude on 07/12/2026
						dir_path, phys.getImageName(), debugLevel);
				break;
			}
			default:
				System.out.println("kernelTest(): unknown kernel test '"+kernel+
						"' - update CuasRT.kernelTest() to match CuasRtParameters.KERNEL_TESTS");
				return;
			}
			if (new File(dir_path, "results").isDirectory()) { // C++ results from a previous run of THIS case
				System.out.println("kernelTest("+kernel+"): NOTE - results/ predate this export if the case just changed");
				AvgTdExport.compareResults(dir_path, debugLevel);
			} else {
				System.out.println("kernelTest("+kernel+"): now run (tile_processor_gpu): tests_bin/test_"+
						kernel+" --data "+dir_path+" --tol 0");
			}
		} catch (IOException e) {
			System.out.println("kernelTest("+kernel+"): export/compare FAILED: "+e.getMessage());
			e.printStackTrace();
		}
	}

	/**
	 * Production target detection (CUDA-free from here on: consumes the plain ImagePlus).
	 * By Claude on 06/24/2026 (moved 07/05/2026).
	 */
	private static void detect(
			final CLTParameters clt_parameters,
			final CuasRanging   cuasRanging,
			final QuadCLT       master_CLT,
			final ImagePlus     imp_targets,
			final UasLogReader  uasLogReader,
			final boolean       batch_mode,
			final int           debugLevel) {
		cuasRanging.saveUasFlightLogCsv(uasLogReader, imp_targets); // UAS flight-log truth -> <name>-UAS_DATA.tsv (mode-0 only; needs QuadCLT pose). By Claude on 06/24/2026
		new CuasDetectRT(
				clt_parameters,               // CLTParameters     clt_parameters,
				uasLogReader,                 // UasLogReader      uasLogReader,
				imp_targets,                  // ImagePlus         imp_targets (no GPU inside CuasDetectRT)
				master_CLT.getX3dDirectory(), // String            model_directory (outputs land like oracle)
				master_CLT.getImageName(),    // String            core_base_name
				master_CLT.correctionsParameters.cuasSynth, // String  cuas_synth_dir (shared, list SET; "" -> model_directory)
				master_CLT.correctionsParameters.cuasNoise, // String  cuas_noise (inline per-level scales, list SET; "" -> sqrt default). By Claude on 06/24/2026
				debugLevel).detectTargets(
						clt_parameters,       // CLTParameters     clt_parameters,
						batch_mode,           // boolean           batch_mode,
						debugLevel);          // int               debugLevel
	}
}
