/**
 ** CuasSceneFeeder.java - in-loop 16-bit raw scene feeder for CUAS RT
 **
 ** Copyright (C) 2026 Elphel, Inc.
 **
 ** -----------------------------------------------------------------------------**
 **
 **  CuasSceneFeeder.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.
 **
 **  CuasSceneFeeder.java 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 CuasSceneFeeder.java.  If not, see <http://www.gnu.org/licenses/>.
 ** -----------------------------------------------------------------------------**
 **
 */
package com.elphel.imagej.cuas.rt;

import java.io.File;
import java.io.RandomAccessFile;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.LockSupport;

import com.elphel.imagej.tileprocessor.QuadCLT;
import com.sun.jna.Pointer;

/**
 * In-loop 16-bit TIFF scene feeder (T2, Andrey's 07/18/2026 ruling): replaces
 * the ~9.7 GiB float all-scene preload with real per-scene file I/O inside the
 * measured loop - the HONEST RT test, and the code shape that stays valid for
 * the production feeder (4 external RAM-based dual-port SATA devices, 4
 * consecutive sensors each; this class reads with 4 threads in exactly those
 * sensor groups).
 *
 * Fast fixed-offset reader: the single-strip 16-bit TIFF layout (byte order,
 * dimensions, strip offset/size, telemetry row count) is parsed ONCE from the
 * first file and applied to every other file (verified by equal file length).
 * The Boson-640 telemetry row (strip top) is skipped at the read offset; Exif
 * parsing is not needed in the loop (DumpTiff oracle 07/18: the production
 * ImagejJp4Tiff output is bit-exactly (float)u16 of strip rows 1..512 - the
 * gains cancel exactly).
 *
 * In pinned mode the reader preads raw strip bytes STRAIGHT into the native
 * pinned u16 staging slot (PosixIo - the kernel's read IS the pinned write),
 * the GPU does byte-swap + borrowed Row/Col + two-map conditioning in one
 * kernel (condition_lwir_u16, bit-exact vs the preload path by the u16_cond
 * case). posix_fadvise(DONTNEED) after each read keeps reruns honestly cold:
 * the production device's frames are always new, page-cache replay would fake
 * the I/O cost.
 *
 * Arrival moderation (60 Hz emulation): arm() gives the feeder the same epoch
 * grid the paced harness uses; a scene's read then starts no earlier than its
 * arrival time, so with the preload gone the DATA - not just the loop - is
 * released at 60 Hz.
 *
 * Borrowed preparation (to be replaced by the slow background calibration per
 * plan): per-scene Row/Col profiles stay CPU-resident on the scenes; their
 * prior-calibration inverse scales are snapshotted at create() - before
 * calibratePhotometric() refines the live scales - exactly like the preload.
 *
 * By Claude on 07/18/2026, from Andrey's T2 design.
 */
public final class CuasSceneFeeder {
	private static final int READER_THREADS = 4; // production shape: one per SATA device group

	private final QuadCLT []  quadCLTs;
	private final String [][] scenePaths;   // [scene][sensor] or null scene
	private final double []   invScales;    // 1/prior lwir_scales, snapshotted at create // [sensor]
	private final boolean     rowcolEnable;
	private final int         numSensors;
	private final int         width;
	private final int         height;       // image rows (telemetry excluded)
	private final boolean     bigEndian;
	private final long        dataOffset;   // strip offset + telemetry rows
	private final long        fileLength;   // every file must match (fixed-offset contract)
	private final int         debugLevel;

	private final ThreadPoolExecutor pool;

	// per-scene staging state (single-threaded control from the pose loop)
	private static final class Job {
		final int nscene;
		final int slot;
		final long arrivalNs;   // Long.MIN_VALUE = immediate
		final CountDownLatch done = new CountDownLatch(READER_THREADS);
		final AtomicBoolean ok = new AtomicBoolean(true);
		short [][] heap;        // heap mode only: [sensor][pixels], raw file byte order
		Job(int nscene, int slot, long arrivalNs) {
			this.nscene = nscene; this.slot = slot; this.arrivalNs = arrivalNs;
		}
	}
	private final Job [] jobs;          // by scene index
	private int  requestCount = 0;      // slot = requestCount & 1 at request time
	private int  nextToRequest = 0;     // next scene index to consider
	private Job  lastRequested = null;
	private Job  prevRequested = null;

	// pinned mode (set by the harness after GPU conditioning is prepared)
	private com.elphel.imagej.gpu.GpuQuad gpuQuad = null;
	private final Pointer [] u16Staging = new Pointer [2];

	// paced arrival grid (armed per pass)
	private long epochNs = 0;
	private long periodNs = 0;
	private boolean paced = false;

	private CuasSceneFeeder(
			final QuadCLT []  quadCLTs,
			final String [][] scenePaths,
			final double []   invScales,
			final boolean     rowcolEnable,
			final int         numSensors,
			final int         width,
			final int         height,
			final boolean     bigEndian,
			final long        dataOffset,
			final long        fileLength,
			final int         debugLevel) {
		this.quadCLTs = quadCLTs;
		this.scenePaths = scenePaths;
		this.invScales = invScales;
		this.rowcolEnable = rowcolEnable;
		this.numSensors = numSensors;
		this.width = width;
		this.height = height;
		this.bigEndian = bigEndian;
		this.dataOffset = dataOffset;
		this.fileLength = fileLength;
		this.debugLevel = debugLevel;
		this.jobs = new Job [quadCLTs.length];
		this.pool = new ThreadPoolExecutor(READER_THREADS, READER_THREADS,
				60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(),
				r -> { Thread t = new Thread(r, "CuasSceneFeeder"); t.setDaemon(true); return t; });
	}

	/**
	 * Build the feeder: per-scene sensor->file mapping (the readRawImageData
	 * rule), fixed-offset layout from file #1, uniform-length verification of
	 * EVERY file, and the same borrowed Row/Col provenance guards as the
	 * preload. Any incomplete scene aborts as a unit (null), exactly like
	 * CuasScenePreload - a configuration error must not silently coast.
	 */
	public static CuasSceneFeeder create(
			final QuadCLT [] quadCLTs,
			final boolean    rowcol_enable,
			final int        debugLevel) {
		if ((quadCLTs == null) || (quadCLTs.length == 0)) return null;
		QuadCLT first = null;
		for (QuadCLT q : quadCLTs) if (q != null) { first = q; break; }
		if (first == null) return null;
		final int num_sens = first.getNumSensors();
		final int [] wh = first.getGeometryCorrection().getSensorWH();
		final String [][] scenePaths = new String [quadCLTs.length][];
		final String [] sourceFiles = first.correctionsParameters.getSourcePaths();
		int mapped_scenes = 0;
		for (int nscene = 0; nscene < quadCLTs.length; nscene++) {
			final QuadCLT scene = quadCLTs[nscene];
			if (scene == null) continue;
			final String set_name = scene.getImageName();
			final String [] paths = new String [num_sens];
			int found = 0;
			for (int nFile = 0; nFile < sourceFiles.length; nFile++) {
				if ((sourceFiles[nFile] == null) || (sourceFiles[nFile].length() <= 1)) continue;
				if (!set_name.equals(scene.correctionsParameters.getNameFromSourceTiff(sourceFiles[nFile]))) continue;
				final int [] channels = scene.fileChannelToSensorChannels(
						scene.correctionsParameters.getChannelFromSourceTiff(sourceFiles[nFile]));
				if (channels == null) continue;
				for (int ch : channels) if ((ch >= 0) && (ch < num_sens) && scene.eyesisCorrections.isChannelEnabled(ch)) {
					if (paths[ch] == null) found++;
					paths[ch] = sourceFiles[nFile];
				}
			}
			if (found != num_sens) {
				System.out.println("CuasSceneFeeder: scene "+nscene+" ("+set_name+") has "+found+
						" of "+num_sens+" source files - feeder NOT built");
				return null;
			}
			scenePaths[nscene] = paths;
			mapped_scenes++;
		}
		if (mapped_scenes == 0) return null;
		// Borrowed Row/Col provenance (the preload rules): prior scales2 must be
		// zero (a quadratic term cannot be mapped back to raw) and every scene
		// must carry complete, dimension-consistent profiles when enabled.
		// Inverse PRIOR scales are snapshotted now - calibratePhotometric() will
		// refine the live scales after this point.
		double [] inv_scales = null;
		if (rowcol_enable) {
			inv_scales = new double [num_sens];
			final double [] scales = first.getLwirScales();
			final double [] scales2 = first.getLwirScales2();
			if (scales2 != null) for (int s = 0; s < scales2.length; s++) {
				if (scales2[s] != 0.0) {
					System.out.println("CuasSceneFeeder: nonzero prior scales2["+s+"]="+scales2[s]+
							" cannot map calibrated Row/Col profiles back to raw - feeder NOT built");
					return null;
				}
			}
			for (int s = 0; s < num_sens; s++) {
				final double sc = ((scales != null) && (s < scales.length)) ? scales[s] : 0.0;
				if (!Double.isFinite(sc) || (sc == 0.0)) {
					System.out.println("CuasSceneFeeder: invalid prior calibration scale for sensor "+s+
							": "+sc+" - feeder NOT built");
					return null;
				}
				inv_scales[s] = 1.0 / sc;
			}
			for (int nscene = 0; nscene < quadCLTs.length; nscene++) {
				final QuadCLT scene = quadCLTs[nscene];
				if (scene == null) continue;
				final double [][] row = scene.getRowCorr();
				final double [][] col = scene.getColCorr();
				if ((row == null) || (col == null)) {
					System.out.println("CuasSceneFeeder: scene "+nscene+" has no borrowed Row/Col profiles - feeder NOT built");
					return null;
				}
				for (int s = 0; s < num_sens; s++) {
					if ((s >= row.length) || (s >= col.length) || (row[s] == null) || (col[s] == null) ||
							(row[s].length != wh[0])) {
						System.out.println("CuasSceneFeeder: incomplete Row/Col for scene "+nscene+
								" sensor "+s+" - feeder NOT built");
						return null;
					}
				}
			}
		}
		// Fixed-offset layout from file #1 + uniform file length everywhere.
		final String firstPath = scenePaths[firstSceneIndex(scenePaths)][0];
		final TiffLayout layout = parseTiffLayout(firstPath);
		if (layout == null) return null; // message printed
		if (layout.width != wh[0]) {
			System.out.println("CuasSceneFeeder: TIFF width "+layout.width+" != sensor width "+wh[0]+
					" - feeder NOT built");
			return null;
		}
		final int image_h = wh[1];
		final int telemetry_rows = layout.stripRows - image_h;
		if ((telemetry_rows < 0) || (telemetry_rows > 1)) {
			System.out.println("CuasSceneFeeder: strip rows "+layout.stripRows+" vs image height "+image_h+
					" (telemetry rows "+telemetry_rows+") unsupported - feeder NOT built");
			return null;
		}
		final long file_len = new File(firstPath).length();
		long checked = 0;
		for (String [] paths : scenePaths) {
			if (paths == null) continue;
			for (String path : paths) {
				if (new File(path).length() != file_len) {
					System.out.println("CuasSceneFeeder: "+path+" length differs from "+file_len+
							" - fixed-offset contract broken, feeder NOT built");
					return null;
				}
				checked++;
			}
		}
		final long data_offset = layout.stripOffset + (long) telemetry_rows * layout.width * 2;
		System.out.println(String.format(
				"CuasSceneFeeder: READY %d scenes x %d sensors, %dx%d %s-endian, strip@%d (+%d telemetry row%s),"+
				" %d files verified @%d bytes; %d reader threads (4-sensor groups, the 4xSATA production shape);"+
				" Row/Col %s; reads are IN the measured loop (fadvise keeps them cold)",
				mapped_scenes, num_sens, layout.width, image_h, layout.bigEndian ? "big" : "little",
				layout.stripOffset, telemetry_rows, (telemetry_rows == 1) ? "" : "s",
				checked, file_len, READER_THREADS,
				rowcol_enable ? "borrowed profiles applied in-kernel (prior scales snapshotted)" : "bypassed"));
		return new CuasSceneFeeder(quadCLTs, scenePaths, inv_scales, rowcol_enable,
				num_sens, layout.width, image_h, layout.bigEndian, data_offset, file_len, debugLevel);
	}

	private static int firstSceneIndex(final String [][] scenePaths) {
		for (int i = 0; i < scenePaths.length; i++) if (scenePaths[i] != null) return i;
		return -1;
	}

	private static final class TiffLayout {
		boolean bigEndian;
		int width, stripRows;
		long stripOffset;
	}
	/** Minimal single-strip 16-bit TIFF IFD parse (file #1 only - the feeder contract). */
	private static TiffLayout parseTiffLayout(final String path) {
		try (RandomAccessFile raf = new RandomAccessFile(path, "r")) {
			final byte [] head = new byte [4096];
			final int n = raf.read(head);
			if (n < 16) { System.out.println("CuasSceneFeeder: "+path+" too short"); return null; }
			final boolean big = (head[0] == 'M') && (head[1] == 'M');
			if (!big && !((head[0] == 'I') && (head[1] == 'I'))) {
				System.out.println("CuasSceneFeeder: "+path+" is not a TIFF"); return null;
			}
			final TiffLayout lt = new TiffLayout();
			lt.bigEndian = big;
			final long ifd = get32(head, 4, big);
			if (ifd + 2 > n) { System.out.println("CuasSceneFeeder: "+path+" IFD beyond header read"); return null; }
			final int entries = get16(head, (int) ifd, big);
			long strip_bytes = -1;
			int bits = -1, compression = 1, spp = 1, height = -1;
			for (int i = 0; i < entries; i++) {
				final int e = (int) ifd + 2 + 12*i;
				if (e + 12 > n) { System.out.println("CuasSceneFeeder: "+path+" IFD entry beyond header read"); return null; }
				final int tag = get16(head, e, big);
				final int typ = get16(head, e + 2, big);
				final long cnt = get32(head, e + 4, big);
				final long val = (typ == 3) ? get16(head, e + 8, big) : get32(head, e + 8, big);
				switch (tag) {
				case 256: lt.width = (int) val; break;
				case 257: height = (int) val; break;
				case 258: bits = (int) val; break;
				case 259: compression = (int) val; break;
				case 273: if (cnt != 1) { System.out.println("CuasSceneFeeder: "+path+" multi-strip - unsupported"); return null; }
				          lt.stripOffset = val; break;
				case 277: spp = (int) val; break;
				case 279: if (cnt != 1) { System.out.println("CuasSceneFeeder: "+path+" multi-strip - unsupported"); return null; }
				          strip_bytes = val; break;
				}
			}
			if ((bits != 16) || (compression != 1) || (spp != 1) || (lt.width <= 0) || (height <= 0) ||
					(lt.stripOffset <= 0) || (strip_bytes != 2L * lt.width * height)) {
				System.out.println("CuasSceneFeeder: "+path+" unsupported layout (bits="+bits+
						" comp="+compression+" spp="+spp+" "+lt.width+"x"+height+
						" strip@"+lt.stripOffset+" bytes="+strip_bytes+")");
				return null;
			}
			lt.stripRows = height;
			return lt;
		} catch (Exception e) {
			System.out.println("CuasSceneFeeder: cannot parse "+path+": "+e);
			return null;
		}
	}
	private static int get16(final byte [] b, final int off, final boolean big) {
		return big ? (((b[off] & 0xff) << 8) | (b[off+1] & 0xff)) :
			(((b[off+1] & 0xff) << 8) | (b[off] & 0xff));
	}
	private static long get32(final byte [] b, final int off, final boolean big) {
		return big ?
			(((long)(b[off] & 0xff) << 24) | ((b[off+1] & 0xff) << 16) | ((b[off+2] & 0xff) << 8) | (b[off+3] & 0xff)) :
			(((long)(b[off+3] & 0xff) << 24) | ((b[off+2] & 0xff) << 16) | ((b[off+1] & 0xff) << 8) | (b[off] & 0xff));
	}

	public int getNumSensors()   { return numSensors; }
	public int getWidth()        { return width; }
	public int getHeight()       { return height; }
	public int getNumPixels()    { return width * height; }
	public boolean isBigEndian() { return bigEndian; }
	public boolean isRowColEnabled() { return rowcolEnable; }
	public boolean isPaced()     { return paced; }

	/**
	 * Switch to pinned mode: reads go straight into the native u16 staging.
	 * Call after GPU conditioning is prepared; false leaves heap mode (CPU oracle).
	 */
	public boolean enablePinned(final com.elphel.imagej.gpu.GpuQuad gpu) {
		if ((gpu == null) || !gpu.supportsU16Conditioning()) return false;
		if ((gpu.getImageWidth() != width) || (gpu.getImageHeight() != height)) {
			System.out.println("CuasSceneFeeder: GPU image "+gpu.getImageWidth()+"x"+gpu.getImageHeight()+
					" != feeder "+width+"x"+height+" - staying in heap mode");
			return false;
		}
		for (int slot = 0; slot < 2; slot++) {
			u16Staging[slot] = gpu.u16Staging(slot);
			if (u16Staging[slot] == null) return false;
		}
		gpuQuad = gpu;
		return true;
	}
	public boolean isPinned() { return gpuQuad != null; }

	/** Arm the 60 Hz arrival grid (paced harness): scene k's read starts no earlier
	 *  than epochNs + k*periodNs (k = request order over non-null scenes). */
	public void arm(final long epochNs, final long periodNs) {
		this.epochNs = epochNs;
		this.periodNs = periodNs;
		this.paced = true;
	}

	/** Bootstrap/rerun support: drain in-flight reads, clear all staging state. */
	public void reset() {
		if (lastRequested != null) try { lastRequested.done.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
		if (prevRequested != null) try { prevRequested.done.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
		java.util.Arrays.fill(jobs, null);
		requestCount = 0;
		nextToRequest = 0;
		lastRequested = null;
		prevRequested = null;
		paced = false;
	}

	public void shutdown() { pool.shutdown(); }

	/**
	 * Kick the asynchronous read of the NEXT unrequested scene. Call sites are the
	 * loop's safe points (all chains through scene N-1 collected), which guarantees
	 * the target staging slot's previous H2D has completed - the same one-scene
	 * slack argument as the float staging path.
	 */
	public void requestNext() {
		while ((nextToRequest < quadCLTs.length) && (scenePaths[nextToRequest] == null)) nextToRequest++;
		if (nextToRequest >= quadCLTs.length) return;
		final int nscene = nextToRequest++;
		final int slot = requestCount & 1;
		final long arrival = paced ? (epochNs + requestCount * periodNs) : Long.MIN_VALUE;
		requestCount++;
		final Job job = new Job(nscene, slot, arrival);
		if (!isPinned()) job.heap = new short [numSensors][];
		jobs[nscene] = job;
		prevRequested = lastRequested;
		lastRequested = job;
		final int group_size = (numSensors + READER_THREADS - 1) / READER_THREADS;
		for (int g = 0; g < READER_THREADS; g++) {
			final int s0 = g * group_size;
			final int s1 = Math.min(numSensors, s0 + group_size);
			pool.execute(() -> readGroup(job, s0, s1));
		}
	}

	public boolean isStaged(final int nscene) {
		final Job job = jobs[nscene];
		return (job != null) && (job.done.getCount() == 0) && job.ok.get();
	}
	public boolean isRequested(final int nscene) { return jobs[nscene] != null; }
	public int getSlot(final int nscene) {
		final Job job = jobs[nscene];
		return (job == null) ? -1 : job.slot;
	}

	/** Block until scene nscene is staged (false = read failed - caller coasts). */
	public boolean awaitStaged(final int nscene) {
		Job job = jobs[nscene];
		if (job == null) {
			// out-of-order request (should not happen in the pose loop) - serve inline
			while ((nextToRequest <= nscene) && (nextToRequest < quadCLTs.length)) requestNext();
			job = jobs[nscene];
			if (job == null) return false;
		}
		try { job.done.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; }
		return job.ok.get();
	}

	/** One reader thread's share: sensors [s0, s1) - a production SATA device group. */
	private void readGroup(final Job job, final int s0, final int s1) {
		try {
			if (job.arrivalNs != Long.MIN_VALUE) {
				for (;;) {
					final long remain = job.arrivalNs - System.nanoTime();
					if (remain <= 0) break;
					LockSupport.parkNanos(remain);
				}
			}
			final String [] paths = scenePaths[job.nscene];
			final long count = 2L * width * height;
			for (int s = s0; s < s1; s++) {
				if (!job.ok.get()) break; // another group already failed
				if (isPinned() && PosixIo.isAvailable()) {
					final int fd = PosixIo.open(paths[s], PosixIo.O_RDONLY);
					if (fd < 0) { fail(job, paths[s], "open failed"); break; }
					try {
						final Pointer dst = u16Staging[job.slot].share((long) s * width * height * 2);
						long got = 0;
						while (got < count) {
							final long r = PosixIo.pread(fd, dst.share(got), count - got, dataOffset + got);
							if (r <= 0) { fail(job, paths[s], "pread returned "+r+" at "+got); break; }
							got += r;
						}
						if (got < count) break;
						PosixIo.posix_fadvise(fd, 0, 0, PosixIo.POSIX_FADV_DONTNEED); // keep reruns cold
					} finally {
						PosixIo.close(fd);
					}
				} else {
					// heap read (CPU oracle mode / PosixIo unavailable)
					final byte [] buf = new byte [(int) count];
					try (RandomAccessFile raf = new RandomAccessFile(paths[s], "r")) {
						raf.seek(dataOffset);
						raf.readFully(buf);
					} catch (Exception e) { fail(job, paths[s], e.toString()); break; }
					if (isPinned()) {
						// pinned staging ALWAYS holds raw file bytes - the kernel swaps
						u16Staging[job.slot].write((long) s * width * height * 2, buf, 0, buf.length);
					} else {
						// heap staging holds DECODED values (no GPU swap downstream)
						final short [] px = new short [width * height];
						for (int i = 0; i < px.length; i++) {
							px[i] = bigEndian ?
									(short) (((buf[2*i] & 0xff) << 8) | (buf[2*i + 1] & 0xff)) :
									(short) (((buf[2*i + 1] & 0xff) << 8) | (buf[2*i] & 0xff));
						}
						job.heap[s] = px;
					}
				}
			}
		} catch (Throwable t) {
			fail(job, "group "+s0+"-"+(s1-1), t.toString());
		} finally {
			job.done.countDown();
		}
	}
	private void fail(final Job job, final String what, final String why) {
		if (job.ok.getAndSet(false)) {
			System.out.println("CuasSceneFeeder: scene "+job.nscene+" read FAILED ("+what+": "+why+")");
		}
	}

	/**
	 * Fill the pinned Row/Col staging block for one scene: rows [cam][width] |
	 * cols [cam][height] | inv_scales [cam], the condition_lwir_u16 layout.
	 * The profiles are the scene's borrowed CPU-resident arrays; the inverse
	 * scales are the create()-time prior-calibration snapshot.
	 */
	public boolean writeRowcolStaging(final int nscene, final Pointer rc) {
		if (!rowcolEnable || (rc == null)) return false;
		final QuadCLT scene = quadCLTs[nscene];
		if (scene == null) return false;
		final double [][] row = scene.getRowCorr();
		final double [][] col = scene.getColCorr();
		if ((row == null) || (col == null)) return false;
		long off = 0;
		for (int s = 0; s < numSensors; s++) {
			if ((row[s] == null) || (row[s].length != width)) return false;
			rc.write(off, row[s], 0, width);
			off += 8L * width;
		}
		for (int s = 0; s < numSensors; s++) {
			if ((col[s] == null) || (col[s].length != height)) return false;
			rc.write(off, col[s], 0, height);
			off += 8L * height;
		}
		rc.write(off, invScales, 0, numSensors);
		return true;
	}

	/**
	 * CPU oracle: the staged u16 scene as conditioned-input floats - byte swap,
	 * borrowed Row/Col in double (the exact applyCalibratedRowColToRaw chain),
	 * ONE float cast. Bit-identical to what CuasScenePreload.getScene() held.
	 */
	public float [][] readSceneFloats(final int nscene, final float [][] out) {
		final Job job = jobs[nscene];
		if ((job == null) || (job.done.getCount() != 0) || !job.ok.get()) return null;
		final QuadCLT scene = quadCLTs[nscene];
		final double [][] row = rowcolEnable ? scene.getRowCorr() : null;
		final double [][] col = rowcolEnable ? scene.getColCorr() : null;
		final int npix = width * height;
		final short [] pinned_buf = isPinned() ? new short [npix] : null;
		for (int s = 0; s < numSensors; s++) {
			final short [] px;
			if (isPinned()) {
				u16Staging[job.slot].read((long) s * npix * 2, pinned_buf, 0, npix);
				px = pinned_buf;
			} else {
				px = job.heap[s];
			}
			if ((px == null) || (out[s] == null) || (out[s].length != npix)) return null;
			final double inv = rowcolEnable ? invScales[s] : 0.0;
			// pinned staging holds raw file bytes (host short read = LE view -> swap
			// when the file is big-endian); heap staging holds decoded values
			final boolean swap = isPinned() && bigEndian;
			for (int i = 0; i < npix; i++) {
				final int v = (swap ? Short.reverseBytes(px[i]) : px[i]) & 0xffff;
				double d = v;
				if (rowcolEnable) {
					d -= (row[s][i % width] + col[s][i / width]) * inv;
				}
				out[s][i] = (float) d;
			}
		}
		return out;
	}
}
