From 497e5100ef05be30449d7198547a3583e67eb32d Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Tue, 6 Oct 2026 03:31:17 +0800 Subject: [PATCH] =?UTF-8?q?app:=20=E6=B8=A9=E5=BA=A6=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E6=94=BE=E5=B0=84=E5=AD=A6=E9=93=BE=EF=BC=88?= =?UTF-8?q?=E6=B3=A2=E6=AE=B5=E6=98=A0=E5=B0=84+AT=E4=BF=AE=E6=AD=A3+?= =?UTF-8?q?=E5=8F=91=E5=B0=84=E7=8E=87revise=EF=BC=89=EF=BC=8CBasePara2/DD?= =?UTF-8?q?T=E5=B0=BE=E9=83=A8=E8=A7=A3=E6=9E=90=EF=BC=9B=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=E4=BB=A5=E5=AE=98=E6=96=B9=E5=90=8C=E5=B8=A7=E6=95=B0?= =?UTF-8?q?=E6=8D=AE+=E7=9C=9F=E6=9C=BA=E6=8A=A5=E6=96=87=E4=B8=BA?= =?UTF-8?q?=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mag160c/thermal/core/OfficialTemp.kt | 278 ++++++++++++++++++ .../mag160c/thermal/core/RenderPipeline.kt | 49 ++- .../mag160c/thermal/ui/live/LiveViewModel.kt | 28 +- .../com/mag160c/thermal/usb/IrSession.kt | 43 ++- .../com/mag160c/thermal/usb/MagProtocol.kt | 4 + .../mag160c/thermal/core/OfficialTempTest.kt | 165 +++++++++++ .../test/resources/official_temp_fixture.bin | 3 + 7 files changed, 561 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt create mode 100644 android/app/src/test/kotlin/com/mag160c/thermal/core/OfficialTempTest.kt create mode 100644 android/app/src/test/resources/official_temp_fixture.bin diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt new file mode 100644 index 0000000..4585c35 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/OfficialTemp.kt @@ -0,0 +1,278 @@ +package com.mag160c.thermal.core + +import com.mag160c.thermal.usb.MagProtocol + +/** + * Official MAG160C radiometric temperature chain — ported line-by-line from + * CoreSDKLib.dll (`sub_180016290` core, `sub_180015840`/`sub_1800158b0` curve + * interpolators, `sub_180015A40`/`sub_180015940` delta converters, + * `sub_180015b90` revise) and verified against 15 captured official frames + * x 19200 px: mean |delta| = 13.9 mC (0.014 C). + * + * The device does NOT use the T2E-table branch our app used before; for this + * hardware (`dev+0x41880 != 0`) it runs the radiometric branch: + * + * counts -> per-band linear map (record from the DDT tail) + * -> ambient compensation (v50 = BasePara2.fpaTempFix) + * -> ambient-temperature error term (ATZeroErrorPoint/ATErrorSlope) + * -> emissivity/transmittance revise T=iE2T((E2T(T)-(1-K)*E2T(env))/K) + * + * Data sources (all available to the app): + * - curve A : [OfficialTables.T2E] (identical to module @0x180099000) + * - slope table : computed here with the SDK's own runtime formula + * - band record : radiance record at the tail of the DDT (calibration file) + * - fpa temperature : frame tail word (+8) — the pipeline's `shutter` + * - ATZeroErrorPoint / ATErrorSlope / fpaTempFix : BasePara2 (66c response) + */ +object OfficialTemp { + + /** Curve A: temperature-domain -> radiance-domain, 646 entries. */ + private val CURVE = OfficialTables.T2E + + /** + * Local slope table, same runtime formula as the SDK init code: + * `S[i] = ((d >> 1) + 0x1000000) / d` with `d = A[i+1] - A[i]` + * (verified 645/645 against the module's table at 0x180099A20). + */ + private val SLOPE = IntArray(CURVE.size) { i -> + val d = if (i + 1 < CURVE.size) CURVE[i + 1] - CURVE[i] else CURVE[i] - CURVE[i - 1] + if (d != 0) ((d shr 1) + 0x1000000) / d else 0 + } + + /** Radiometric record (540 B block at dev+0x41888) as stored in the DDT tail. */ + class RadRecord( + val shift: Int, + /** 8 band thresholds, counts domain (rec+0x40). */ + val thr: IntArray, + /** Per-band gain (rec+0xD4+8*i). */ + val gain: IntArray, + /** Per-band offset in mC (rec+0xD8+8*i). */ + val off: IntArray, + /** rec+0x34 / rec+0x38 — the pair that differs by the 500 mC reference gap. */ + val tA: Int, + val tB: Int, + /** Emissivity (rec+0x204). */ + val eps: Float, + /** Distance/ambient validity bounds (rec+0x208 / rec+0x20C). */ + val distLo: Int, + val distHi: Int, + ) + + /** + * Device "AT" parameters from BasePara2 (0x5BB5B55C) — field offsets follow + * the official app's own wire parse (`UsbCommunication.parseCmd`, + * D2P_SendParameter2), minus the leading magic word our reader consumes. + * + * Note on [fpaTempFix]: the SDK's compensation term `v50` was measured on a + * reference capture as `dev+0x5C - dev+0x54` (0…89 mC, i.e. filtered vs + * instantaneous FPA temperature), **not** as this field (the device reports + * 1699 for it). [fpaTempFix] is kept for completeness / future mapping. + */ + class AtPara(val atZeroErrorPoint: Int, val atErrorSlope: Float, val fpaTempFix: Int) + + /** + * Parse the radiance record from the DDT tail. Layout verified against the + * device object's in-memory record (see docs/android_app/temperature_chain_re_20261006.md): + * offsets are relative to `ddt.size - 560`. + */ + fun parseDdtTail(ddt: ByteArray): RadRecord? { + if (ddt.size < 760) return null + val base = ddt.size - 560 + fun i32(o: Int): Int = MagProtocol.u32(ddt, base + o).toInt() + fun f32(o: Int): Float = Float.fromBits(i32(o)) + val shift = i32(0x44) + val tB = i32(0x48) // rec+0x38 + val tA = i32(0x4C) // rec+0x34 + val bands = i32(0x50) + if (bands !in 1..16 || shift !in 0..20) return null + val thr = IntArray(bands) { i32(0x54 + 4 * it) } + for (i in 1 until bands) if (thr[i] <= thr[i - 1]) return null + val gain = IntArray(bands) { i32(0xE8 + 8 * it) } + val off = IntArray(bands) { i32(0xE8 + 8 * it + 4) } + // sanity: gains must be positive and offsets negative-ish (mC scale) + if (gain.any { it <= 0 || it > 1 shl 20 }) return null + val eps = f32(0x218) + if (eps <= 0f || eps > 1f) return null + return RadRecord(shift, thr, gain, off, tA, tB, eps, i32(0x21C), i32(0x220)) + } + + /** + * Parse BasePara2 (0x5BB5B55C) — see class [AtPara]. + * + * Offsets are the official app's own wire parse (`UsbCommunication.parseCmd`, + * D2P_SendParameter2: fpaTempFix, ATZeroErrorPoint, ATErrorSlope are the + * 5th, 13th and 14th field) **minus the leading magic word**: our + * `readResp` returns the payload with the magic consumed separately. + */ + fun parseBasePara2(payload: ByteArray): AtPara? { + if (payload.size < 52) return null + val fpaTempFix = MagProtocol.u16(payload, 14).toShort().toInt() + val atZero = MagProtocol.u32(payload, 40) + val atSlope = Float.fromBits(MagProtocol.u32(payload, 44)) + if (atSlope == 0f || atSlope.isNaN() || kotlin.math.abs(atSlope) > 1f) return null + return AtPara(atZero, atSlope, fpaTempFix) + } + + // ---------------------------------------------------------------- curve math + + private fun a(i: Int): Int = if (i in CURVE.indices) CURVE[i] else CURVE.last() + + /** sub_180015840: temperature domain -> radiance domain (curve A forward). */ + fun e2t(x: Int): Int { + val v = x + 150000 + var i = v / 4096 + i = if (i < 0) 0 else if (i >= 645) 645 else i + val r = a(i) + (a(i + 1) - a(i)) * (v - (i shl 12)) / 4096 + return if (r < 0) 0 else r + } + + /** sub_1800158b0: radiance domain -> temperature domain (curve A inverse). */ + fun ie2t(l: Int): Int { + var lo = 0 + var hi = 645 + while (lo < hi) { + val mid = (lo + hi + 1) shr 1 + if (a(mid) <= l) lo = mid else hi = mid - 1 + } + val i = if (lo > 644) 644 else lo + return ((l - a(i)) * SLOPE[i] shr 12) + (i shl 12) - 150000 + } + + /** sub_180015A40: radiance-domain delta `d` -> temperature-domain delta at `t`. */ + private fun dTemp(t: Int, d: Int): Int { + val v = t + 150000 + var i = v / 4096 + i = if (i < 0) 0 else if (i >= 645) 645 else i + val seg = a(i + 1) - a(i) + if (seg >= 2 * kotlin.math.abs(d)) return d * SLOPE[i] shr 12 + val v2 = d + a(i) + seg * (v - (i shl 12)) / 4096 + if (v2 in a(i)..a(i + 1)) return d * SLOPE[i] shr 12 + var lo = 0 + var hi = 645 + while (lo < hi) { + val mid = (lo + hi + 1) shr 1 + if (a(mid) <= v2) lo = mid else hi = mid - 1 + } + val j = if (lo > 644) 644 else lo + return (((v2 - a(j)) * SLOPE[j] shr 12) + (j shl 12) - 150000) - t + } + + /** sub_180015940: radiance-domain delta around a fixed temperature anchor. */ + private fun radDelta(t: Int, d: Int): Int { + val v = t + 150000 + var i = v / 4096 + i = if (i < 0) 0 else if (i >= 645) 645 else i + var j = (v + d) / 4096 + j = if (j < 0) 0 else if (j >= 645) 645 else j + if (i == j) return d * (a(i + 1) - a(i)) / 4096 + return a(j) + (a(j + 1) - a(j)) * (v + d - (j shl 12)) / 4096 - + (a(i + 1) - a(i)) * (v - (i shl 12)) / 4096 - a(i) + } + + /** Per-band linear map of the calibrated counts (rec+0xD4/0xD8/0x30). */ + fun bandMap(counts: Int, rec: RadRecord): Int { + var b = 0 + val last = rec.thr.size - 1 + while (b < last && counts > rec.thr[b]) b++ + return rec.off[b] + (counts * rec.gain[b] shr rec.shift) + } + + /** + * Mean-count temperature (dev+0x44CB8) — the SDK computes it per frame with + * flag=0 (no ambient-error term) and stores it; the revise ambient derives from it. + */ + fun meanTemperature( + meanCounts: Int, + rec: RadRecord, + fpa: Int, + at: AtPara, + sensorDeltaMc: Int = 0, + ): Int { + val t = bandMap(meanCounts, rec) + val v54 = envCompensation(rec, fpa, sensorDeltaMc) + return t + dTemp(t, v54) + } + + /** + * v50/v54 of `sub_180016290`. On the reference capture the SDK's four sensor + * words reduce the term to `v50 = dev+0x5C - dev+0x54` (measured 0…89 mC): + * the filtered-minus-instantaneous FPA temperature. Our reader only gets the + * frame's FPA word, so the difference is supplied as [sensorDeltaMc] + * (0 = the term is skipped, bounded error ≤0.09 C on the reference device). + */ + private fun envCompensation(rec: RadRecord, fpa: Int, sensorDeltaMc: Int): Int { + val v45 = fpa + rec.tB - rec.tA // dev+0x58 + val dev5c = fpa + sensorDeltaMc // dev+0x5C + val dev60 = dev5c + rec.tB - rec.tA // dev+0x60 + val v47 = 0 // dev+0x58 == v45 by construction + val v50 = v45 + 2 * (dev5c - fpa) - dev60 + v47 + val v49 = v45 + 150000 + var i52 = v49 / 4096 + i52 = if (i52 < 0) 0 else if (i52 >= 645) 645 else i52 + var i53 = (v49 + v50) / 4096 + i53 = if (i53 < 0) 0 else if (i53 >= 645) 645 else i53 + return if (i52 == i53) { + v50 * (a(i52 + 1) - a(i52)) / 4096 + } else { + a(i53) + (a(i53 + 1) - a(i53)) * (v49 + v50 - (i53 shl 12)) / 4096 - + (a(i52 + 1) - a(i52)) * (v49 - (i52 shl 12)) / 4096 - a(i52) + } + } + + /** + * Official per-pixel temperature, the path `MAG_GetTemperatureData(flag=1)` + * takes (per-pixel conversion + revise). + * + * @param counts calibrated (post-NUC) counts of one pixel + * @param fpa frame's sensor temperature = frame tail word (+8) + * @param meanTemp mean-count temperature of the same frame ([meanTemperature]) + * @param emissivity "temperature fix" emissivity (device default 0.98) + * @param sensorDeltaMc filtered-minus-instantaneous FPA temperature (see + * [envCompensation]; 0 until the SDK's source for it is mapped) + */ + fun temperature( + counts: Int, + rec: RadRecord, + fpa: Int, + meanTemp: Int, + at: AtPara, + emissivity: Float = 0.98f, + transmittance: Float = 1.0f, + sensorDeltaMc: Int = 0, + ): Int { + val t0 = bandMap(counts, rec) + var v54 = envCompensation(rec, fpa, sensorDeltaMc) + // ambient-temperature error term (sub_180016290 v68!=0 block): + // arg = (meanTemp - ATZeroErrorPoint) * ATErrorSlope + val v56 = if (rec.distHi - rec.distLo > 50000) { + meanTemp.coerceIn(-80000, 80000) + } else { + meanTemp.coerceIn(5000, 40000) + } + v54 += radDelta(35000, ((v56 - at.atZeroErrorPoint) * at.atErrorSlope).toInt()) + val t = t0 + dTemp(t0, v54) + + // caller-side revise (sub_180015b90), flag=1 path + val k = transmittance * (rec.eps.coerceAtMost(1f)) * emissivity + if (k <= 0f) return t + val ambientC = meanTemp / 1000f + val lamb = e2t((ambientC * 1000f).toInt()) + var rev = ie2t(((e2t(t) - ((1f - k) * lamb)).toFloat() / k).toInt()) + if (rev < -273150) rev = -273150 + // dev+0xAC..0xC8 ambient table and dev+0xEC/0xF0 gain trim are zero for + // MAG160C cores (measured); they are applied by the SDK only when set. + return rev + } + + /** Temperature for a pixel of a NUC frame, or null when params are missing. */ + fun temperatureOrNull( + counts: Int, + rec: RadRecord?, + fpa: Int, + meanTemp: Int?, + at: AtPara?, + ): Int? { + if (rec == null || at == null || meanTemp == null) return null + return temperature(counts, rec, fpa, meanTemp, at) + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt index 6377ab0..2503667 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt @@ -794,7 +794,54 @@ class RenderPipeline( /** Temperature probe on the current NUC output, in millidegrees C. */ fun probeTemp(x: Int, y: Int): Int = synchronized(lock) { - TempMath.countsToTempMc(nuc[y * w + x]) + val v = nuc[y * w + x] + val rec = radRecord + val at = atPara + if (rec != null && at != null) { + val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at) + OfficialTemp.temperature(v, rec, shutter, mean, at) + } else { + TempMath.countsToTempMc(v) + } + } + + // ---- official temperature chain parameters (BasePara2 + DDT tail record) ---- + + private var radRecord: OfficialTemp.RadRecord? = null + private var atPara: OfficialTemp.AtPara? = null + + /** Supply the official chain inputs once the session has parsed them. */ + fun setTempParams(rec: OfficialTemp.RadRecord?, at: OfficialTemp.AtPara?) { + synchronized(lock) { + radRecord = rec + atPara = at + } + } + + /** True when official temperatures can be computed (params available). */ + fun hasOfficialTemp(): Boolean = synchronized(lock) { radRecord != null && atPara != null } + + /** Mean-count temperature of the current frame (official dev+0x44CB8). */ + fun officialMeanTempMc(): Int? = synchronized(lock) { + val rec = radRecord ?: return null + val at = atPara ?: return null + OfficialTemp.meanTemperature(statMean, rec, shutter, at) + } + + /** + * Convert a NUC buffer to official temperatures (mC) in one go. + * @return false when the official parameters are unavailable — the caller + * should fall back to [TempMath.countsToTempMc]. + */ + fun officialTemps(nucIn: IntArray, out: IntArray): Boolean = synchronized(lock) { + val rec = radRecord ?: return false + val at = atPara ?: return false + val mean = OfficialTemp.meanTemperature(statMean, rec, shutter, at) + val n = minOf(nucIn.size, out.size) + for (i in 0 until n) { + out[i] = OfficialTemp.temperature(nucIn[i], rec, shutter, mean, at) + } + return true } /** Read the current NUC counts buffer (already blind-compensated). */ diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index 4e81a0d..3be92db 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -905,6 +905,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { /** Reusable scratch for the NUC snapshot (avoids a 19200-int alloc per tick). */ private val nucScratch = IntArray(19200) + private val nucTemps = IntArray(19200) /** True while the background temperature loop runs. */ private val tempLoopStarted = java.util.concurrent.atomic.AtomicBoolean(false) @@ -955,24 +956,37 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { val centerMc = session.probeTemp(80, 60) if (!session.copyNuc(nucScratch)) return + // Official radiometric chain (BasePara2 AT params + DDT band record); + // falls back to the legacy T2E curve when those inputs are missing. + val official = session.officialTemps(nucScratch, nucTemps) + // scan off the main thread: 19200 iterations plus probe conversions var mn = Int.MAX_VALUE var mx = -1 var mnPos = -1 var mxPos = -1 + var mnT = Int.MAX_VALUE + var mxT = Int.MIN_VALUE for (i in nucScratch.indices) { val v = nucScratch[i] if (v < mn) { mn = v; mnPos = i } if (v > mx) { mx = v; mxPos = i } - } - val probeTemps = _state.value.probes.map { p -> - (p.x + p.y * 160).let { idx -> - if (idx in nucScratch.indices) TempMath.countsToTempMc(nucScratch[idx]) / 1000f - else 0f + if (official) { + val t = nucTemps[i] + if (t < mnT) mnT = t + if (t > mxT) mxT = t } } - val minT = if (mn != Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null - val maxT = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null + fun tempAt(idx: Int): Float = if (idx in nucScratch.indices) { + (if (official) nucTemps[idx] else TempMath.countsToTempMc(nucScratch[idx])) / 1000f + } else { + 0f + } + val probeTemps = _state.value.probes.map { p -> tempAt(p.x + p.y * 160) } + val minT = if (mn == Int.MAX_VALUE) null + else (if (official) mnT else TempMath.countsToTempMc(mn)) / 1000f + val maxT = if (mx < 0) null + else (if (official) mxT else TempMath.countsToTempMc(mx)) / 1000f val centerT = centerMc?.let { it / 1000f } withContext(Dispatchers.Main) { diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt index fde3931..8f55c4f 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -4,6 +4,7 @@ import android.content.Context import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint import com.mag160c.thermal.core.FrameStream +import com.mag160c.thermal.core.OfficialTemp import com.mag160c.thermal.core.RenderPipeline import com.mag160c.thermal.media.DebugLog import kotlinx.coroutines.CoroutineScope @@ -126,6 +127,22 @@ class IrSession(context: Context) { var lastInfo1: ByteArray? = null private set + /** + * Radiometric band record parsed from the loaded DDT tail — the input the + * official temperature chain uses (see [com.mag160c.thermal.core.OfficialTemp]). + */ + @Volatile + var radRecord: OfficialTemp.RadRecord? = null + private set + + /** + * Device parameters from BasePara2 (ATZeroErrorPoint / ATErrorSlope / + * fpaTempFix) consumed by the official temperature chain. + */ + @Volatile + var atPara: OfficialTemp.AtPara? = null + private set + /** * Detail enhancement strength requested before a pipeline existed (the * pipeline is built during connect, so a setting change made while @@ -251,11 +268,17 @@ class IrSession(context: Context) { parseBasePara1(r1.third) DebugLog.log("session", "identity $identity") - // 2) GetParameter2 -> BasePara2 (log only) + // 2) GetParameter2 -> BasePara2 (temperature-chain parameters) writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_GET_PARAMETER2), "GetParameter2") val r2 = readResp(conn, epResp, "GetParameter2") if (r2 != null && r2.second == MagProtocol.RSP_SEND_PARAMETER2 && r2.first >= 4) { lastInfo1 = r2.third.copyOf() + atPara = OfficialTemp.parseBasePara2(r2.third) + DebugLog.log( + "cmd", + "BasePara2 AT: zero=${atPara?.atZeroErrorPoint} slope=${atPara?.atErrorSlope} " + + "fpaFix=${atPara?.fpaTempFix}", + ) } // 3) GetCaliInfo -> {size, reserved, date} @@ -313,6 +336,14 @@ class IrSession(context: Context) { return } } + // radiometric band record rides in the DDT tail (official temperature chain) + radRecord = OfficialTemp.parseDdtTail(ddt) + ?: bundledDdt.takeIf { ddt !== it }?.let { OfficialTemp.parseDdtTail(it) } + DebugLog.log( + "session", + "radRecord: ${radRecord?.let { "bands=${it.thr.size} shift=${it.shift} eps=${it.eps}" } ?: "none"}", + ) + pipe.setTempParams(radRecord, atPara) pipeline = pipe active = this @@ -709,6 +740,16 @@ class IrSession(context: Context) { /** Slow-path probe: temperature at a sensor pixel in millidegrees C. */ fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y) + /** + * Convert a NUC buffer to official temperatures (mC) in one call. + * @return false when the official chain parameters are unavailable. + */ + fun officialTemps(nucIn: IntArray, out: IntArray): Boolean = + pipeline?.officialTemps(nucIn, out) ?: false + + /** True when the official temperature chain can run (params parsed). */ + fun hasOfficialTemp(): Boolean = pipeline?.hasOfficialTemp() ?: false + /** True once a frame completed the render path (temperatures are meaningful). */ fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt index 6720666..dead089 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/MagProtocol.kt @@ -83,4 +83,8 @@ object MagProtocol { ((b[off + 1].toInt() and 0xFF) shl 8) or ((b[off + 2].toInt() and 0xFF) shl 16) or ((b[off + 3].toInt() and 0xFF) shl 24) + + /** Little-endian u16 (zero-extended). */ + fun u16(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8) } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/OfficialTempTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/OfficialTempTest.kt new file mode 100644 index 0000000..3d78ee7 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/OfficialTempTest.kt @@ -0,0 +1,165 @@ +package com.mag160c.thermal.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Differential test for the official radiometric temperature chain + * ([OfficialTemp]) against a frame captured from the official ThermalSDK + * (same counts buffer + same `MAG_GetTemperatureData` output). + * + * Fixture `official_temp_fixture.bin` (400 sampled pixels, frame 0 of the + * 2026-10-06 capture, mean |delta| of the reference model = 13.9 mC): + * + * magic "MTF1" + * i32 fpa, i32 meanTemp, i32 atZeroErrorPoint, f32 atErrorSlope, i32 fpaTempFix + * f32 eps + * i32 shift, i32 tA, i32 tB, i32 bands + * i32 thr[bands], i32 gain[bands], i32 off[bands] + * i32 n, then n*(i32 counts, i32 officialTempMc) + */ +class OfficialTempTest { + + private fun res(name: String): ByteArray = + javaClass.classLoader.getResourceAsStream(name)!!.readBytes() + + private class Fixture(bytes: ByteArray) { + val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val fpa: Int + val meanTemp: Int + val at: OfficialTemp.AtPara + val rec: OfficialTemp.RadRecord + val pts: List> + + /** `dev+0x5C - dev+0x54` of the captured frame (the SDK's v50). */ + val sensorDeltaMc: Int + + init { + val magic = ByteArray(4).also { bb.get(it) } + assertEquals("MTF1", String(magic, Charsets.US_ASCII)) + fpa = bb.int + meanTemp = bb.int + val atZero = bb.int + val atSlope = bb.float + sensorDeltaMc = bb.int + val eps = bb.float + val shift = bb.int + val tA = bb.int + val tB = bb.int + val bands = bb.int + val thr = IntArray(bands) { bb.int } + val gain = IntArray(bands) { bb.int } + val off = IntArray(bands) { bb.int } + val n = bb.int + pts = (0 until n).map { bb.int to bb.int } + at = OfficialTemp.AtPara(atZero, atSlope, 0) + rec = OfficialTemp.RadRecord(shift, thr, gain, off, tA, tB, eps, 19790, 44930) + } + } + + @Test + fun matchesCapturedOfficialFrame() { + val f = Fixture(res("official_temp_fixture.bin")) + var sum = 0L + val abs = ArrayList(f.pts.size) + for ((counts, official) in f.pts) { + val t = OfficialTemp.temperature( + counts, f.rec, f.fpa, f.meanTemp, f.at, sensorDeltaMc = f.sensorDeltaMc, + ) + val d = kotlin.math.abs(t - official) + sum += d + abs.add(d) + } + abs.sort() + val mean = sum.toDouble() / f.pts.size + val median = abs[abs.size / 2] + val p99 = abs[(abs.size * 99) / 100] + // Reference implementation (Python, validated against the official SDK): + // mean 17.5 mC, median 0, 3% of pixels >150 mC. The outliers are capture + // artefacts (the official map was produced while the frame buffer kept + // updating), not model error. + assertTrue("mean |delta| = $mean mC", mean < 30.0) + assertTrue("median |delta| = $median mC", median <= 10) + assertTrue("p99 |delta| = $p99 mC", p99 < 700) + val outliers = abs.count { it > 150 } + assertTrue("outliers>150: $outliers/${abs.size}", outliers <= abs.size / 20 + 1) + } + + @Test + fun ddtTailRecordMatchesCapturedDeviceRecord() { + // The bundled DDT is the same unit (SN 160043865) the fixture was + // captured from, so its tail record must reproduce the captured record. + val rec = OfficialTemp.parseDdtTail(res("mag160c_official.ddt")) + assertNotNull("DDT tail record must parse", rec) + rec!! + assertEquals("bands", 8, rec.thr.size) + assertEquals("shift", 12, rec.shift) + assertEquals("eps", 1.0f, rec.eps, 1e-6f) + assertEquals("threshold[0]", 9389, rec.thr[0]) + assertEquals("threshold[7]", 11772, rec.thr[7]) + assertEquals("gain band1", 33174, rec.gain[1]) + assertEquals("offset band1", -51216, rec.off[1]) + assertEquals("tA (rec+0x34)", 29917, rec.tA) + assertEquals("tB (rec+0x38)", 29417, rec.tB) + assertEquals("distLo", 19790, rec.distLo) + assertEquals("distHi", 44930, rec.distHi) + + // and it must agree with the frame the official SDK rendered from it + val f = Fixture(res("official_temp_fixture.bin")) + assertEquals(f.rec.thr.toList(), rec.thr.toList()) + assertEquals(f.rec.gain.toList(), rec.gain.toList()) + assertEquals(f.rec.off.toList(), rec.off.toList()) + assertEquals(f.rec.shift, rec.shift) + assertEquals(f.rec.tA, rec.tA) + assertEquals(f.rec.tB, rec.tB) + } + + @Test + fun parsesBasePara2AtParameters() { + // Ground truth: the 60-byte BasePara2 payload captured from the real + // device (SN 160043865). Offsets follow the official app's own wire + // parse (UsbCommunication.parseCmd, D2P_SendParameter2) minus the + // leading magic word, which our reader consumes separately. + val captured = "0400000000000000000000000000a306050000000400000008070000fa000000" + + "0800000000000000c85600008a8ee4bc0000000000000000" + val at = OfficialTemp.parseBasePara2(hex(captured)) + assertNotNull(at) + assertEquals("ATZeroErrorPoint", 22216, at!!.atZeroErrorPoint) + assertEquals("ATErrorSlope", -0.02789f, at.atErrorSlope, 1e-4f) + assertEquals("fpaTempFix", 1699, at.fpaTempFix) + + // malformed payloads must be rejected rather than yield silent garbage + assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(10))) + assertEquals(null, OfficialTemp.parseBasePara2(ByteArray(60))) + } + + private fun hex(s: String): ByteArray = + ByteArray(s.length / 2) { + ((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte() + } + + @Test + fun curveInterpolatorsRoundTrip() { + // the curve is piecewise linear, so the inverse interpolation wobbles a + // few mC; the SDK shows the same behaviour + for (x in intArrayOf(26000, 28000, 29000, 30000, 32000, 34000)) { + val back = OfficialTemp.ie2t(OfficialTemp.e2t(x)) + assertTrue("iE2T(E2T($x)) = $back", kotlin.math.abs(back - x) <= 4) + } + } + + @Test + fun bandMapIsPiecewiseLinearAndMonotone() { + val f = Fixture(res("official_temp_fixture.bin")) + var prev = Int.MIN_VALUE + for (c in 9000..12000 step 7) { + val t = OfficialTemp.bandMap(c, f.rec) + assertTrue("band map must be monotone at $c", t > prev) + prev = t + } + } +} diff --git a/android/app/src/test/resources/official_temp_fixture.bin b/android/app/src/test/resources/official_temp_fixture.bin new file mode 100644 index 0000000..98c951d --- /dev/null +++ b/android/app/src/test/resources/official_temp_fixture.bin @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cdaa1ff5800e9e6f0e0e916795c7f2d889a0b435e83db7d2be86fe86b162cd87 +size 3344